Initialer Laravel Commit für BetiX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

This commit is contained in:
2026-04-04 18:01:50 +02:00
commit 0280278978
374 changed files with 65210 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { router } from '@inertiajs/vue3';
const isLoading = ref(false);
const progress = ref(0);
let progressTimer: number | undefined;
let hideTimer: number | undefined;
function startLoading() {
clearTimeout(hideTimer);
clearInterval(progressTimer);
isLoading.value = true;
progress.value = 5;
// Simulate progress ticking up to ~85% while waiting
progressTimer = window.setInterval(() => {
if (progress.value < 85) {
progress.value += Math.random() * 8;
if (progress.value > 85) progress.value = 85;
}
}, 200);
}
function stopLoading() {
clearInterval(progressTimer);
progress.value = 100;
hideTimer = window.setTimeout(() => {
isLoading.value = false;
progress.value = 0;
}, 350);
}
let offStart: (() => void) | undefined;
let offFinish: (() => void) | undefined;
onMounted(() => {
offStart = router.on('start', startLoading);
offFinish = router.on('finish', stopLoading);
});
onUnmounted(() => {
offStart?.();
offFinish?.();
clearTimeout(hideTimer);
clearInterval(progressTimer);
});
</script>
<template>
<transition name="app-loading-fade">
<div v-if="isLoading" class="app-loading-overlay" aria-hidden="true">
<div class="al-bar" :style="{ width: progress + '%' }"></div>
<div class="al-spinner">
<div class="al-ring"></div>
<div class="al-brand">Beti<span>X</span></div>
</div>
</div>
</transition>
</template>
<style scoped>
.app-loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(5, 5, 5, 0.75);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.al-bar {
position: absolute;
top: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, var(--primary, #df006a), #ff8800);
border-radius: 0 2px 2px 0;
transition: width 0.2s ease;
box-shadow: 0 0 12px var(--primary, #df006a);
}
.al-spinner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.al-ring {
width: 52px;
height: 52px;
border: 3px solid rgba(255, 255, 255, 0.08);
border-top-color: var(--primary, #df006a);
border-radius: 50%;
animation: al-spin 0.75s linear infinite;
}
.al-brand {
font-size: 1.3rem;
font-weight: 900;
color: #fff;
letter-spacing: 0.02em;
}
.al-brand span {
color: var(--primary, #df006a);
}
@keyframes al-spin {
to { transform: rotate(360deg); }
}
/* Fade transition */
.app-loading-fade-enter-active,
.app-loading-fade-leave-active {
transition: opacity 0.25s ease;
}
.app-loading-fade-enter-from,
.app-loading-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps<{
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
requireHold?: boolean; // If true, user must hold button for 3s
}>();
const emit = defineEmits(['close', 'confirm']);
const holdProgress = ref(0);
let holdInterval: number | undefined;
const startHold = () => {
if (!props.requireHold) return;
holdProgress.value = 0;
holdInterval = window.setInterval(() => {
holdProgress.value += 2; // 50 * 2 = 100% in ~2.5s (adjusted for UX)
if (holdProgress.value >= 100) {
stopHold();
emit('confirm');
}
}, 50); // Update every 50ms
};
const stopHold = () => {
if (holdInterval) {
clearInterval(holdInterval);
holdInterval = undefined;
}
if (holdProgress.value < 100) {
holdProgress.value = 0;
}
};
const onConfirmClick = () => {
if (!props.requireHold) {
emit('confirm');
}
};
watch(() => props.isOpen, (val) => {
if (!val) {
stopHold();
holdProgress.value = 0;
}
});
</script>
<template>
<transition name="fade">
<div v-if="isOpen" class="modal-overlay">
<div class="modal-card">
<div class="modal-head">{{ title }}</div>
<div class="modal-body">{{ message }}</div>
<div class="modal-actions">
<button class="btn-cancel" @click="$emit('close')">{{ cancelText || 'Cancel' }}</button>
<button
class="btn-confirm"
:class="{ 'holding': requireHold }"
@mousedown="startHold"
@mouseup="stopHold"
@mouseleave="stopHold"
@touchstart.prevent="startHold"
@touchend.prevent="stopHold"
@click="onConfirmClick"
>
<div v-if="requireHold" class="hold-fill" :style="{ width: `${holdProgress}%` }"></div>
<span class="btn-text">{{ confirmText || 'Confirm' }}</span>
<span v-if="requireHold && holdProgress > 0" class="hold-hint">Hold...</span>
</button>
</div>
</div>
</div>
</transition>
</template>
<style scoped>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(5px); z-index: 9999; display: flex; align-items: center; justify-content: center; }
.modal-card { background: #0a0a0a; border: 1px solid #222; border-radius: 16px; padding: 24px; width: 90%; max-width: 400px; box-shadow: 0 20px 50px rgba(0,0,0,0.8); animation: popIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); }
.modal-head { font-size: 16px; font-weight: 900; color: #fff; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }
.modal-body { font-size: 13px; color: #bbb; margin-bottom: 24px; line-height: 1.5; }
.modal-actions { display: flex; gap: 12px; justify-content: flex-end; }
.btn-cancel { background: transparent; border: 1px solid #333; color: #888; padding: 10px 16px; border-radius: 8px; font-weight: 700; cursor: pointer; transition: 0.2s; }
.btn-cancel:hover { color: #fff; border-color: #555; }
.btn-confirm { background: #ff007a; border: none; color: #fff; padding: 10px 20px; border-radius: 8px; font-weight: 900; cursor: pointer; position: relative; overflow: hidden; transition: 0.2s; }
.btn-confirm:hover { box-shadow: 0 0 15px rgba(255,0,122,0.4); }
.btn-confirm.holding { background: #33001a; border: 1px solid #ff007a; }
.hold-fill { position: absolute; top: 0; left: 0; height: 100%; background: #ff007a; transition: width 0.05s linear; z-index: 0; }
.btn-text { position: relative; z-index: 1; }
.hold-hint { position: absolute; right: 10px; font-size: 9px; opacity: 0.7; z-index: 1; top: 50%; transform: translateY(-50%); }
@keyframes popIn { from { opacity: 0; transform: scale(0.9); } to { opacity: 1; transform: scale(1); } }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ChevronDown, Search, Check } from 'lucide-vue-next';
const props = defineProps<{
modelValue: string;
placeholder?: string;
error?: boolean;
}>();
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const searchQuery = ref('');
const containerRef = ref<HTMLElement | null>(null);
// Full Country List with Codes
const countries = [
{ code: 'DE', name: 'Germany' }, { code: 'AT', name: 'Austria' }, { code: 'CH', name: 'Switzerland' },
{ code: 'US', name: 'United States' }, { code: 'GB', name: 'United Kingdom' }, { code: 'FR', name: 'France' },
{ code: 'IT', name: 'Italy' }, { code: 'ES', name: 'Spain' }, { code: 'NL', name: 'Netherlands' },
{ code: 'BE', name: 'Belgium' }, { code: 'PL', name: 'Poland' }, { code: 'CZ', name: 'Czech Republic' },
{ code: 'DK', name: 'Denmark' }, { code: 'SE', name: 'Sweden' }, { code: 'NO', name: 'Norway' },
{ code: 'FI', name: 'Finland' }, { code: 'PT', name: 'Portugal' }, { code: 'GR', name: 'Greece' },
{ code: 'TR', name: 'Turkey' }, { code: 'RU', name: 'Russia' }, { code: 'UA', name: 'Ukraine' },
{ code: 'CA', name: 'Canada' }, { code: 'AU', name: 'Australia' }, { code: 'JP', name: 'Japan' },
{ code: 'CN', name: 'China' }, { code: 'BR', name: 'Brazil' }, { code: 'MX', name: 'Mexico' },
{ code: 'AR', name: 'Argentina' }, { code: 'IN', name: 'India' }, { code: 'ZA', name: 'South Africa' },
{ code: 'AE', name: 'United Arab Emirates' }, { code: 'KR', name: 'South Korea' }, { code: 'SG', name: 'Singapore' }
];
const filteredCountries = computed(() => {
if (!searchQuery.value) return countries;
return countries.filter(c => c.name.toLowerCase().includes(searchQuery.value.toLowerCase()));
});
const selectedCountry = computed(() => {
return countries.find(c => c.code === props.modelValue);
});
const toggle = () => isOpen.value = !isOpen.value;
const select = (code: string) => {
emit('update:modelValue', code);
isOpen.value = false;
searchQuery.value = '';
};
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
isOpen.value = false;
}
};
onMounted(() => document.addEventListener('click', handleClickOutside));
onUnmounted(() => document.removeEventListener('click', handleClickOutside));
</script>
<template>
<div class="relative" ref="containerRef">
<!-- Trigger Button -->
<div
@click="toggle"
class="flex items-center justify-between h-10 w-full rounded-md border bg-[#0a0a0a] px-3 py-2 text-sm cursor-pointer transition-all duration-200"
:class="[
error ? 'border-red-500' : isOpen ? 'border-[#00f2ff] ring-1 ring-[#00f2ff]' : 'border-[#151515] hover:border-[#333]'
]"
>
<div class="flex items-center gap-2" v-if="selectedCountry">
<img :src="`https://flagcdn.com/w20/${selectedCountry.code.toLowerCase()}.png`" class="w-5 h-3.5 object-cover rounded-sm" />
<span class="text-white font-medium">{{ selectedCountry.name }}</span>
</div>
<span v-else class="text-[#888]">{{ placeholder || 'Select Country' }}</span>
<ChevronDown class="w-4 h-4 text-[#666] transition-transform duration-200" :class="{ 'rotate-180': isOpen }" />
</div>
<!-- Dropdown Menu -->
<transition name="dropdown">
<div v-if="isOpen" class="absolute z-50 mt-2 w-full rounded-md border border-[#151515] bg-[#0a0a0a] shadow-[0_10px_40px_rgba(0,0,0,0.8)] overflow-hidden">
<!-- Search -->
<div class="p-2 border-b border-[#151515]">
<div class="relative">
<Search class="absolute left-2 top-2.5 w-3.5 h-3.5 text-[#666]" />
<input
v-model="searchQuery"
type="text"
placeholder="Search..."
class="w-full bg-[#111] border border-[#222] rounded-md py-1.5 pl-8 pr-3 text-xs text-white focus:outline-none focus:border-[#00f2ff] transition-colors"
autofocus
/>
</div>
</div>
<!-- List -->
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<div
v-for="country in filteredCountries"
:key="country.code"
@click="select(country.code)"
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-[#151515] transition-colors group"
:class="{ 'bg-[#151515]': modelValue === country.code }"
>
<div class="flex items-center gap-3">
<img :src="`https://flagcdn.com/w20/${country.code.toLowerCase()}.png`" class="w-5 h-3.5 object-cover rounded-sm opacity-80 group-hover:opacity-100 transition-opacity" />
<span class="text-sm text-[#ccc] group-hover:text-white transition-colors">{{ country.name }}</span>
</div>
<Check v-if="modelValue === country.code" class="w-3.5 h-3.5 text-[#00f2ff]" />
</div>
<div v-if="filteredCountries.length === 0" class="px-3 py-4 text-center text-xs text-[#666]">
No country found.
</div>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #0a0a0a;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #333;
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps<{
modelValue: string; // ISO Date String (YYYY-MM-DD)
error?: boolean;
}>();
const emit = defineEmits(['update:modelValue']);
// Internal state for raw input
const inputValue = ref('');
// Format: YYYY-MM-DD -> DD.MM.YYYY for display
const formatDate = (iso: string) => {
if (!iso) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
};
// Initialize
watch(() => props.modelValue, (val) => {
// Only update if the formatted value is different to avoid cursor jumping
const formatted = formatDate(val);
if (inputValue.value !== formatted && val.length === 10) {
inputValue.value = formatted;
}
}, { immediate: true });
const onInput = (e: Event) => {
let val = (e.target as HTMLInputElement).value.replace(/\D/g, ''); // Remove non-digits
// Auto-insert dots
if (val.length > 2) val = val.slice(0, 2) + '.' + val.slice(2);
if (val.length > 5) val = val.slice(0, 5) + '.' + val.slice(5);
if (val.length > 10) val = val.slice(0, 10);
inputValue.value = val;
// Only emit if full date is entered
if (val.length === 10) {
const [d, m, y] = val.split('.');
// Basic validation
const day = parseInt(d);
const month = parseInt(m);
const year = parseInt(y);
if (day > 0 && day <= 31 && month > 0 && month <= 12 && year > 1900 && year < 2100) {
emit('update:modelValue', `${y}-${m}-${d}`);
}
} else if (val.length === 0) {
emit('update:modelValue', '');
}
};
</script>
<template>
<div class="relative w-full">
<input
type="text"
:value="inputValue"
@input="onInput"
placeholder="DD.MM.YYYY"
class="flex h-10 w-full rounded-md border bg-[#0a0a0a] px-3 py-2 text-sm text-white shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#00f2ff] disabled:cursor-not-allowed disabled:opacity-50 placeholder:text-[#444]"
:class="error ? 'border-red-500' : 'border-[#151515] hover:border-[#333]'"
maxlength="10"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-[#666]">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="4" rx="2" ry="2"/><line x1="16" x2="16" y1="2" y2="6"/><line x1="8" x2="8" y1="2" y2="6"/><line x1="3" x2="21" y1="10" y2="10"/></svg>
</div>
</div>
</template>

View File

@@ -0,0 +1,258 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
</script>
<template>
<footer class="main-footer">
<div class="bg-grid"></div>
<div class="footer-content">
<div class="footer-top">
<div class="footer-section about">
<h3 class="footer-title">Beti<span class="highlight">X</span></h3>
<p>The ultimate crypto gaming protocol. Experience fair play, instant withdrawals, and exclusive rewards. Join the revolution of decentralized gambling.</p>
</div>
<div class="footer-section links">
<h3 class="footer-title">Quick Links</h3>
<Link href="/vip-levels">VIP Club</Link>
<Link href="/bonuses">Promotions</Link>
<Link href="/guilds">Guilds</Link>
<Link href="/trophy">Trophy Room</Link>
</div>
<div class="footer-section links">
<h3 class="footer-title">Help & Support</h3>
<Link href="/faq">Help Center</Link>
<Link href="/self-exclusion">Responsible Gaming</Link>
<Link href="/legal/aml">Fairness & AML</Link>
<Link href="/legal/disputes">Disputes</Link>
</div>
<div class="footer-section links">
<h3 class="footer-title">Legal</h3>
<Link href="/legal/terms">{{ $t('footer.legal.terms') }}</Link>
<Link href="/legal/cookies">{{ $t('footer.legal.cookies') }}</Link>
<Link href="/legal/privacy">{{ $t('footer.legal.privacy') }}</Link>
<Link href="/legal/bonus-policy">{{ $t('footer.legal.bonusPolicy') }}</Link>
<Link href="/legal/disputes">{{ $t('footer.legal.disputes') }}</Link>
<Link href="/legal/responsible-gaming">{{ $t('footer.legal.responsible') }}</Link>
<Link href="/legal/aml">{{ $t('footer.legal.aml') }}</Link>
<Link href="/legal/risk-warnings">{{ $t('footer.legal.risks') }}</Link>
</div>
</div>
<div class="footer-middle">
<h3 class="footer-title text-center">Our Partners & Providers</h3>
<div class="partner-logos">
<div class="logo-item">Pragmatic Play</div>
<div class="logo-item">Nolimit City</div>
<div class="logo-item">Hacksaw</div>
<div class="logo-item">Push Gaming</div>
<div class="logo-item">Evolution</div>
<div class="logo-item">Play'n GO</div>
</div>
<div class="crypto-logos">
<span class="crypto-icon"></span>
<span class="crypto-icon">Ξ</span>
<span class="crypto-icon">Ł</span>
<span class="crypto-icon"></span>
</div>
</div>
<div class="footer-bottom">
<div class="licenses">
<div class="license-badge">18+</div>
<div class="license-badge">GC</div>
<div class="license-badge">RNG Certified</div>
<div class="license-text">
Betix.io is operated by Betix Group N.V., registered under No. 123456, Curacao. Licensed and regulated by the Government of Curacao.
<br>Gambling can be addictive. Please play responsibly. <a href="#" class="text-link">BeGambleAware.org</a>
</div>
</div>
<div class="copyright">
&copy; {{ new Date().getFullYear() }} Betix Protocol. All rights reserved.
</div>
</div>
</div>
</footer>
</template>
<style scoped>
.main-footer {
background: #050505;
border-top: 1px solid #151515;
padding: 60px 30px 30px;
margin-top: 80px;
font-size: 13px;
color: #888;
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 50px 50px;
mask-image: radial-gradient(circle at center, black 40%, transparent 80%);
pointer-events: none;
}
.footer-content {
max-width: 1400px;
margin: 0 auto;
position: relative;
z-index: 1;
}
.footer-top {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 40px;
margin-bottom: 60px;
}
.footer-title {
font-size: 14px;
font-weight: 900;
color: #fff;
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 20px;
}
.highlight { color: #ff007a; }
.footer-section p {
line-height: 1.6;
max-width: 300px;
}
.footer-section a {
display: block;
color: #888;
text-decoration: none;
margin-bottom: 10px;
transition: all 0.2s;
}
.footer-section a:hover {
color: #00f2ff;
transform: translateX(5px);
}
.footer-middle {
border-top: 1px solid #151515;
border-bottom: 1px solid #151515;
padding: 40px 0;
margin-bottom: 40px;
text-align: center;
}
.partner-logos {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 30px;
margin-bottom: 30px;
}
.logo-item {
font-weight: 800;
text-transform: uppercase;
font-size: 16px;
color: #555;
cursor: pointer;
transition: all 0.3s;
}
.logo-item:hover {
color: #fff;
transform: scale(1.1);
text-shadow: 0 0 10px rgba(255,255,255,0.5);
}
.crypto-logos {
display: flex;
justify-content: center;
gap: 20px;
}
.crypto-icon {
font-size: 20px;
color: #fff;
opacity: 0.5;
transition: all 0.3s;
cursor: pointer;
}
.crypto-icon:hover {
opacity: 1;
transform: translateY(-3px);
text-shadow: 0 0 10px #fff;
}
.footer-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
text-align: center;
}
.licenses {
display: flex;
flex-direction: column;
align-items: center;
gap: 15px;
}
.license-badge {
display: inline-block;
border: 1px solid #333;
padding: 5px 10px;
border-radius: 4px;
font-size: 10px;
font-weight: 900;
color: #666;
margin: 0 5px;
transition: all 0.3s;
cursor: default;
}
.license-badge:hover {
border-color: #666;
color: #fff;
}
.license-text {
font-size: 11px;
color: #555;
line-height: 1.5;
max-width: 600px;
}
.text-link {
color: #888;
text-decoration: underline;
}
.copyright {
font-size: 11px;
color: #333;
}
@media (max-width: 1000px) {
.footer-top {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 600px) {
.footer-top {
grid-template-columns: 1fr;
text-align: center;
}
.footer-section p {
margin: 0 auto 20px;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<script setup>
import { ref, nextTick, onMounted, watch } from 'vue';
const props = defineProps({
toasts: {
type: Array,
required: true
}
});
const emit = defineEmits(['close']);
const closeToast = (id) => {
emit('close', id);
};
// Re-run lucide icons when toasts change
watch(() => props.toasts, () => {
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
}, { deep: true });
onMounted(() => {
nextTick(() => {
if (window.lucide) window.lucide.createIcons();
});
});
</script>
<template>
<teleport to="body">
<div id="notif-container">
<div v-for="toast in toasts" :key="toast.id"
:class="['toast', toast.type, { active: toast.active, 'fly-out': toast.flyingOut }]"
@click="closeToast(toast.id)">
<div class="toast-icon"><i :data-lucide="toast.icon"></i></div>
<div style="flex:1">
<div class="toast-title">{{ toast.title }}</div>
<div class="toast-desc">{{ toast.desc }}</div>
</div>
<div class="toast-progress" :style="{ transform: `scaleX(${toast.progress/100})` }"></div>
</div>
</div>
</teleport>
</template>
<style scoped>
#notif-container { position: fixed; top: 85px; right: 20px; display: flex; flex-direction: column; gap: 12px; z-index: 5000; pointer-events: auto; backdrop-filter: none; }
.toast { background: rgba(13,13,13,0.95); border: 1px solid #222; padding: 14px 20px; border-radius: 12px; display: flex; align-items: center; gap: 14px; width: 300px; transform: translateX(120%); transition: 0.4s cubic-bezier(0.2, 0, 0, 1); box-shadow: 0 10px 40px rgba(0,0,0,0.9); position: relative; overflow: hidden; cursor: pointer; backdrop-filter: none; }
.toast.active { transform: translateX(0); }
.toast.fly-out { transform: translate(100px, -200px) scale(0); opacity: 0; }
.toast-icon { padding: 8px; border-radius: 10px; color: white; display: flex; align-items: center; justify-content: center; }
.toast-progress { position: absolute; bottom: 0; left: 0; height: 3px; background: rgba(255,255,255,0.1); width: 100%; transform-origin: left; }
.toast:hover { transform: scale(1.05) translateX(-5px); z-index: 1001; }
.toast.green { border-left: 4px solid var(--green); }
.toast.green .toast-icon { background: rgba(0,255,157,0.1); color: var(--green); }
.toast.magenta { border-left: 4px solid var(--magenta); }
.toast.magenta .toast-icon { background: rgba(255,0,122,0.1); color: var(--magenta); }
.toast-title { font-size: 11px; font-weight: 900; color: #fff; letter-spacing: 0.5px; margin-bottom: 2px; text-transform: uppercase; }
.toast-desc { font-size: 11px; color: #bbb; font-weight: 500; }
:global(:root) {
--green: #00ff9d;
--magenta: #ff007a;
}
</style>

View File

@@ -0,0 +1,589 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { Link } from '@inertiajs/vue3';
import {
Search, X, Clock, Gamepad2, AtSign, Layers,
Play, ChevronRight, Loader2, SearchX, UserX, Sparkles
} from 'lucide-vue-next';
const emit = defineEmits<{ (e: 'close'): void }>();
const query = ref('');
const inputRef = ref<HTMLInputElement | null>(null);
const gameResults = ref<any[]>([]);
const userResults = ref<any[]>([]);
const providerResults = ref<any[]>([]);
const allGames = ref<any[]>([]);
const loading = ref(false);
const mode = computed(() => {
const q = query.value;
if (q.startsWith('@')) return 'users';
if (q.toLowerCase().startsWith('p:') || q.toLowerCase().startsWith('p ')) return 'providers';
return 'games';
});
const searchTerm = computed(() => {
if (mode.value === 'users') return query.value.slice(1).trim();
if (mode.value === 'providers') return query.value.slice(2).trim();
return query.value.trim();
});
onMounted(async () => {
nextTick(() => inputRef.value?.focus());
try {
const res = await fetch('/api/games');
if (res.ok) {
const data = await res.json();
const list = Array.isArray(data) ? data : (data?.games || data?.items || []);
allGames.value = list.map((g: any, idx: number) => ({
slug: g.slug ?? g.id ?? String(idx),
name: g.name ?? g.title ?? `Game ${idx}`,
provider: g.provider ?? 'BetiX',
image: g.image ?? g.thumbnail ?? '',
type: g.type ?? 'slot',
}));
}
} catch {}
});
let debounceTimer: any;
watch(query, () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(), 250);
});
async function doSearch() {
const term = searchTerm.value;
if (!term) {
gameResults.value = [];
userResults.value = [];
providerResults.value = [];
return;
}
if (mode.value === 'games') {
const lc = term.toLowerCase();
gameResults.value = allGames.value.filter(g => g.name.toLowerCase().includes(lc)).slice(0, 12);
providerResults.value = [];
userResults.value = [];
} else if (mode.value === 'providers') {
const lc = term.toLowerCase();
const map: Record<string, any[]> = {};
for (const g of allGames.value) {
if (g.provider.toLowerCase().includes(lc)) {
if (!map[g.provider]) map[g.provider] = [];
map[g.provider].push(g);
}
}
providerResults.value = Object.entries(map).slice(0, 8).map(([name, games]) => ({ name, count: games.length }));
gameResults.value = [];
userResults.value = [];
} else if (mode.value === 'users') {
if (term.length < 1) { userResults.value = []; return; }
loading.value = true;
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(term)}`);
if (res.ok) userResults.value = (await res.json()).slice(0, 8);
} catch {}
loading.value = false;
gameResults.value = [];
providerResults.value = [];
}
}
// Recent searches
const RECENT_KEY = 'betix_recent_searches';
const recentSearches = ref<string[]>([]);
onMounted(() => {
try { recentSearches.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch {}
});
function addRecent(term: string) {
const list = [term, ...recentSearches.value.filter(r => r !== term)].slice(0, 6);
recentSearches.value = list;
try { localStorage.setItem(RECENT_KEY, JSON.stringify(list)); } catch {}
}
function clearRecent() {
recentSearches.value = [];
try { localStorage.removeItem(RECENT_KEY); } catch {}
}
function applyRecent(term: string) { query.value = term; }
// Keyboard
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close');
}
onMounted(() => document.addEventListener('keydown', handleKeydown));
onUnmounted(() => document.removeEventListener('keydown', handleKeydown));
function playGame(slug: string, name: string, provider?: string) {
addRecent(name);
emit('close');
const prov = (provider ?? 'betix').toLowerCase().replace(/\s+/g, '-');
window.location.href = `/games/play/${encodeURIComponent(prov)}/${encodeURIComponent(slug)}`;
}
function goProfile(username: string) {
addRecent('@' + username);
emit('close');
}
function goProvider(name: string) {
addRecent('P:' + name);
emit('close');
window.location.href = `/dashboard?provider=${encodeURIComponent(name)}`;
}
// Quick shortcuts (shown on empty state)
const shortcuts = [
{ label: '@Username', hint: 'Spieler suchen', prefix: '@', icon: AtSign },
{ label: 'P:Provider', hint: 'Anbieter suchen', prefix: 'P:', icon: Layers },
];
</script>
<template>
<teleport to="body">
<div class="sm-overlay" @click.self="$emit('close')">
<div class="sm-modal">
<!-- Search input row -->
<div class="sm-input-row">
<div class="sm-input-wrap">
<Search :size="18" class="sm-search-icon" />
<input
ref="inputRef"
v-model="query"
class="sm-input"
placeholder="Spiele, @User, P:Provider..."
autocomplete="off"
spellcheck="false"
/>
<button v-if="query" class="sm-clear-btn" @click="query = ''" title="Löschen">
<X :size="15" />
</button>
</div>
<button class="sm-esc-btn" @click="$emit('close')">
<kbd>ESC</kbd>
</button>
</div>
<!-- Mode tabs -->
<div class="sm-tabs">
<button class="sm-tab" :class="{ active: mode === 'games' }" @click="query = query.startsWith('@') || query.toLowerCase().startsWith('p') ? '' : query">
<Gamepad2 :size="13" /> Spiele
</button>
<button class="sm-tab" :class="{ active: mode === 'users' }" @click="query = '@'">
<AtSign :size="13" /> @Spieler
</button>
<button class="sm-tab" :class="{ active: mode === 'providers' }" @click="query = 'P:'">
<Layers :size="13" /> Provider
</button>
<div class="sm-tab-indicator" :style="{ left: mode === 'games' ? '4px' : mode === 'users' ? 'calc(33.3% + 2px)' : 'calc(66.6% + 2px)', width: 'calc(33.3% - 4px)' }"></div>
</div>
<!-- Body -->
<div class="sm-body">
<!-- Empty state -->
<div v-if="!query || (!searchTerm && mode !== 'games')">
<!-- Recent searches -->
<div v-if="recentSearches.length" class="sm-block">
<div class="sm-block-head">
<span><Clock :size="12" /> Zuletzt gesucht</span>
<button class="sm-text-btn" @click="clearRecent">Löschen</button>
</div>
<div class="sm-tags">
<button
v-for="r in recentSearches"
:key="r"
class="sm-tag"
@click="applyRecent(r)"
>{{ r }}</button>
</div>
</div>
<!-- Shortcuts -->
<div class="sm-block">
<div class="sm-block-head"><span><Sparkles :size="12" /> Schnellsuche</span></div>
<div class="sm-shortcuts">
<button
v-for="s in shortcuts"
:key="s.prefix"
class="sm-shortcut"
@click="query = s.prefix"
>
<component :is="s.icon" :size="15" class="sm-sc-icon" />
<div>
<div class="sm-sc-label">{{ s.label }}</div>
<div class="sm-sc-hint">{{ s.hint }}</div>
</div>
<ChevronRight :size="14" class="sm-sc-arrow" />
</button>
</div>
</div>
<!-- Empty hint -->
<div v-if="!recentSearches.length" class="sm-empty">
<Search :size="32" class="sm-empty-icon" />
<p>Tippe um zu suchen</p>
<small>Spiele, Spieler oder Provider</small>
</div>
</div>
<!-- Game results -->
<div v-if="mode === 'games' && searchTerm && gameResults.length" class="sm-block">
<div class="sm-block-head">
<span><Gamepad2 :size="12" /> Spiele</span>
<span class="sm-count">{{ gameResults.length }} Treffer</span>
</div>
<div class="sm-games-grid">
<button
v-for="g in gameResults"
:key="g.slug"
class="sm-game"
@click="playGame(g.slug, g.name, g.provider)"
>
<div class="sg-thumb" :style="g.image ? { backgroundImage: `url(${g.image})` } : {}">
<span v-if="!g.image" class="sg-letter">{{ g.name[0] }}</span>
<div class="sg-overlay"><Play :size="18" /></div>
</div>
<div class="sg-name">{{ g.name }}</div>
<div class="sg-prov">{{ g.provider }}</div>
</button>
</div>
</div>
<!-- No game results -->
<div v-if="mode === 'games' && searchTerm && !gameResults.length" class="sm-no-results">
<SearchX :size="36" class="sm-nr-icon" />
<p>Keine Spiele für <strong>"{{ searchTerm }}"</strong></p>
</div>
<!-- Provider results -->
<div v-if="mode === 'providers' && searchTerm && providerResults.length" class="sm-block">
<div class="sm-block-head"><span><Layers :size="12" /> Provider</span></div>
<div class="sm-providers">
<button
v-for="p in providerResults"
:key="p.name"
class="sm-provider"
@click="goProvider(p.name)"
>
<div class="sp-icon"><Layers :size="18" /></div>
<div class="sp-info">
<div class="sp-name">{{ p.name }}</div>
<div class="sp-count">{{ p.count }} Spiele</div>
</div>
<ChevronRight :size="15" class="sp-arrow" />
</button>
</div>
</div>
<!-- No provider results -->
<div v-if="mode === 'providers' && searchTerm && !providerResults.length" class="sm-no-results">
<SearchX :size="36" class="sm-nr-icon" />
<p>Keine Provider für <strong>"{{ searchTerm }}"</strong></p>
</div>
<!-- User results -->
<div v-if="mode === 'users' && searchTerm && userResults.length" class="sm-block">
<div class="sm-block-head"><span><AtSign :size="12" /> Spieler</span></div>
<div class="sm-users">
<Link
v-for="u in userResults"
:key="u.id"
:href="`/profile/${u.username}`"
class="sm-user"
@click="goProfile(u.username)"
>
<div class="su-avatar">
<img v-if="u.avatar || u.avatar_url" :src="u.avatar || u.avatar_url" alt="" />
<span v-else>{{ u.username[0].toUpperCase() }}</span>
</div>
<div class="su-info">
<div class="su-name">{{ u.username }}</div>
<div class="su-vip">VIP {{ u.vip_level ?? 0 }}</div>
</div>
<ChevronRight :size="14" class="su-arrow" />
</Link>
</div>
</div>
<!-- Loading users -->
<div v-if="mode === 'users' && loading" class="sm-loading">
<Loader2 :size="24" class="sm-spin" />
</div>
<!-- No user results -->
<div v-if="mode === 'users' && searchTerm && !loading && !userResults.length" class="sm-no-results">
<UserX :size="36" class="sm-nr-icon" />
<p>Keine Spieler gefunden</p>
</div>
</div>
<!-- Footer hint -->
<div class="sm-footer">
<span><kbd></kbd> Navigieren</span>
<span><kbd></kbd> Öffnen</span>
<span><kbd>ESC</kbd> Schließen</span>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────────────────── */
.sm-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(8px);
z-index: 9999;
display: flex; align-items: flex-start; justify-content: center;
padding-top: clamp(48px, 9vh, 110px);
animation: sm-overlay-in .15s ease;
}
@keyframes sm-overlay-in { from { opacity: 0; } to { opacity: 1; } }
/* ── Modal ───────────────────────────────────────────────── */
.sm-modal {
width: min(700px, calc(100vw - 32px));
background: #0b0b0e;
border: 1px solid rgba(255,255,255,.08);
border-radius: 20px;
overflow: hidden;
box-shadow:
0 0 0 1px rgba(255,255,255,.04),
0 40px 100px rgba(0,0,0,.9),
0 0 60px rgba(223,0,106,.05);
animation: sm-modal-in .22s cubic-bezier(.16,1,.3,1);
}
@keyframes sm-modal-in {
from { opacity: 0; transform: translateY(-18px) scale(.97); }
to { opacity: 1; transform: none; }
}
/* ── Input row ───────────────────────────────────────────── */
.sm-input-row {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px 14px 20px;
border-bottom: 1px solid rgba(255,255,255,.05);
}
.sm-input-wrap {
flex: 1; display: flex; align-items: center; gap: 12px;
background: rgba(255,255,255,.04);
border: 1px solid rgba(255,255,255,.07);
border-radius: 12px; padding: 0 12px;
transition: border-color .2s, box-shadow .2s;
}
.sm-input-wrap:focus-within {
border-color: rgba(223,0,106,.35);
box-shadow: 0 0 0 3px rgba(223,0,106,.08);
}
.sm-search-icon { color: #555; flex-shrink: 0; transition: color .2s; }
.sm-input-wrap:focus-within .sm-search-icon { color: var(--primary, #df006a); }
.sm-input {
flex: 1; background: transparent; border: none; outline: none;
color: #fff; font-size: 15px; font-weight: 500; font-family: inherit;
height: 46px; min-width: 0;
}
.sm-input::placeholder { color: #333; }
.sm-clear-btn {
background: rgba(255,255,255,.06); border: none; cursor: pointer;
color: #666; border-radius: 8px; width: 28px; height: 28px;
display: flex; align-items: center; justify-content: center; transition: .15s;
}
.sm-clear-btn:hover { color: #fff; background: rgba(255,255,255,.1); }
.sm-esc-btn {
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.09);
border-radius: 8px; padding: 4px 10px; cursor: pointer; transition: .15s; flex-shrink: 0;
}
.sm-esc-btn kbd { font-size: 11px; color: #555; font-family: monospace; letter-spacing: .5px; }
.sm-esc-btn:hover kbd { color: #aaa; }
/* ── Tabs ────────────────────────────────────────────────── */
.sm-tabs {
display: flex; position: relative;
border-bottom: 1px solid rgba(255,255,255,.05);
padding: 6px 8px;
gap: 0;
}
.sm-tab {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px;
height: 34px; background: none; border: none; cursor: pointer;
font-size: 12px; font-weight: 700; color: #444;
border-radius: 8px; transition: color .2s; position: relative; z-index: 1;
letter-spacing: .5px;
}
.sm-tab.active { color: #fff; }
.sm-tab-indicator {
position: absolute; bottom: 6px; height: 34px;
background: rgba(255,255,255,.07);
border: 1px solid rgba(255,255,255,.08);
border-radius: 8px;
transition: left .25s cubic-bezier(.16,1,.3,1);
z-index: 0;
}
/* ── Body ────────────────────────────────────────────────── */
.sm-body {
padding: 14px; max-height: 58vh; overflow-y: auto;
scrollbar-width: thin; scrollbar-color: #222 transparent;
}
.sm-body::-webkit-scrollbar { width: 4px; }
.sm-body::-webkit-scrollbar-thumb { background: #222; border-radius: 2px; }
/* ── Block ───────────────────────────────────────────────── */
.sm-block { margin-bottom: 18px; }
.sm-block-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.sm-block-head > span {
display: flex; align-items: center; gap: 5px;
font-size: 10px; font-weight: 800; color: #444;
text-transform: uppercase; letter-spacing: 1px;
}
.sm-count { font-size: 10px; color: #333; font-weight: 700; }
.sm-text-btn { background: none; border: none; color: #444; font-size: 11px; cursor: pointer; font-weight: 700; transition: color .15s; }
.sm-text-btn:hover { color: #888; }
/* ── Recent tags ─────────────────────────────────────────── */
.sm-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.sm-tag {
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
color: #777; padding: 5px 12px; border-radius: 20px;
font-size: 12px; font-weight: 600; cursor: pointer; transition: .15s;
}
.sm-tag:hover { background: rgba(223,0,106,.1); border-color: rgba(223,0,106,.25); color: #fff; }
/* ── Shortcuts ───────────────────────────────────────────── */
.sm-shortcuts { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.sm-shortcut {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.06);
border-radius: 10px; cursor: pointer; transition: .15s; text-align: left;
}
.sm-shortcut:hover { border-color: rgba(223,0,106,.2); background: rgba(223,0,106,.04); }
.sm-sc-icon { color: var(--primary, #df006a); flex-shrink: 0; }
.sm-sc-label { font-size: 12px; font-weight: 700; color: #aaa; }
.sm-sc-hint { font-size: 11px; color: #444; margin-top: 1px; }
.sm-sc-arrow { color: #333; margin-left: auto; flex-shrink: 0; }
/* ── Empty state ─────────────────────────────────────────── */
.sm-empty { text-align: center; padding: 32px 0 16px; color: #333; }
.sm-empty-icon { margin: 0 auto 12px; opacity: .3; }
.sm-empty p { font-size: 14px; font-weight: 600; color: #444; margin: 0 0 4px; }
.sm-empty small { font-size: 12px; color: #2a2a2a; }
/* ── Games grid ──────────────────────────────────────────── */
.sm-games-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
}
.sm-game {
background: none; border: 1px solid rgba(255,255,255,.05);
border-radius: 10px; overflow: hidden; cursor: pointer;
transition: border-color .15s, transform .15s; text-align: left;
padding: 0;
}
.sm-game:hover { border-color: rgba(223,0,106,.3); transform: translateY(-2px); }
.sg-thumb {
width: 100%; aspect-ratio: 4/3;
background: #1a1a1e; background-size: cover; background-position: center;
position: relative; display: flex; align-items: center; justify-content: center;
}
.sg-letter { font-size: 24px; font-weight: 900; color: #333; }
.sg-overlay {
position: absolute; inset: 0; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center;
color: var(--primary, #df006a); opacity: 0; transition: opacity .15s;
}
.sm-game:hover .sg-overlay { opacity: 1; }
.sg-name {
font-size: 11px; font-weight: 700; color: #ccc;
padding: 6px 8px 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.sg-prov { font-size: 10px; color: #444; padding: 0 8px 6px; }
/* ── Providers ───────────────────────────────────────────── */
.sm-providers { display: flex; flex-direction: column; gap: 4px; }
.sm-provider {
display: flex; align-items: center; gap: 12px; padding: 10px 12px;
background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.05);
border-radius: 10px; cursor: pointer; transition: .15s; text-align: left; width: 100%;
}
.sm-provider:hover { border-color: rgba(223,0,106,.25); background: rgba(223,0,106,.04); }
.sp-icon { color: #444; }
.sm-provider:hover .sp-icon { color: var(--primary, #df006a); }
.sp-info { flex: 1; }
.sp-name { font-size: 14px; font-weight: 700; color: #ddd; }
.sp-count { font-size: 11px; color: #555; margin-top: 1px; }
.sp-arrow { color: #333; }
/* ── Users ───────────────────────────────────────────────── */
.sm-users { display: flex; flex-direction: column; gap: 3px; }
.sm-user {
display: flex; align-items: center; gap: 12px; padding: 8px 10px;
border-radius: 10px; text-decoration: none; transition: background .15s;
}
.sm-user:hover { background: rgba(255,255,255,.04); }
.su-avatar {
width: 38px; height: 38px; border-radius: 50%;
background: #1a1a1e; overflow: hidden; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-weight: 900; color: #444; font-size: 16px;
border: 1px solid rgba(255,255,255,.08);
}
.su-avatar img { width: 100%; height: 100%; object-fit: cover; }
.su-info { flex: 1; }
.su-name { font-size: 14px; font-weight: 700; color: #ddd; }
.su-vip { font-size: 11px; color: #444; margin-top: 1px; }
.su-arrow { color: #333; }
/* ── Loading ─────────────────────────────────────────────── */
.sm-loading { display: flex; justify-content: center; padding: 24px; }
.sm-spin { color: var(--primary, #df006a); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── No results ──────────────────────────────────────────── */
.sm-no-results { text-align: center; padding: 32px 0; }
.sm-nr-icon { color: #2a2a2a; margin: 0 auto 12px; }
.sm-no-results p { font-size: 13px; color: #444; }
.sm-no-results strong { color: #666; }
/* ── Footer ──────────────────────────────────────────────── */
.sm-footer {
display: flex; align-items: center; gap: 16px; justify-content: center;
padding: 10px 16px;
border-top: 1px solid rgba(255,255,255,.04);
background: rgba(0,0,0,.3);
}
.sm-footer span {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: #333;
}
.sm-footer kbd {
background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.08);
border-radius: 4px; padding: 1px 5px; font-family: monospace;
font-size: 10px; color: #555;
}
/* ── Mobile ──────────────────────────────────────────────── */
@media (max-width: 480px) {
.sm-overlay { align-items: flex-end; padding-top: 0; }
.sm-modal { border-radius: 20px 20px 0 0; }
.sm-footer { display: none; }
.sm-games-grid { grid-template-columns: repeat(3, 1fr); }
.sm-shortcuts { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,3 @@
<template>
<hr class="my-6 border-t border-muted" />
</template>

View File

@@ -0,0 +1 @@
export { default as Button } from './button.vue';

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive' | 'outline';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}>();
const classes = computed(() => {
const base = "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
const variants = {
primary: "bg-[#ff007a] text-white hover:bg-[#ff007a]/90 shadow-[0_0_15px_rgba(255,0,122,0.4)]",
secondary: "bg-[#00f2ff] text-black hover:bg-[#00f2ff]/80 shadow-[0_0_15px_rgba(0,242,255,0.4)]",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive: "bg-red-500 text-white hover:bg-red-500/90",
outline: "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground text-white"
};
const sizes = {
sm: "h-8 px-3 text-xs",
md: "h-9 px-4 py-2",
lg: "h-10 px-8"
};
return `${base} ${variants[props.variant || 'primary']} ${sizes[props.size || 'md']}`;
});
</script>
<template>
<button :type="type || 'button'" :class="classes" :disabled="disabled">
<slot />
</button>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
checked?: boolean;
disabled?: boolean;
id?: string;
name?: string;
}>();
defineEmits(['update:checked']);
</script>
<template>
<input
type="checkbox"
:id="id"
:name="name"
:checked="checked"
:disabled="disabled"
@change="$emit('update:checked', ($event.target as HTMLInputElement).checked)"
class="h-4 w-4 rounded border-[#151515] bg-[#0a0a0a] text-[#ff007a] focus:ring-[#ff007a] focus:ring-offset-[#020202] disabled:cursor-not-allowed disabled:opacity-50"
/>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(defineProps<{
modelValue: string;
maxlength?: number;
disabled?: boolean;
autofocus?: boolean;
id?: string;
}>(), {
modelValue: '',
maxlength: 6,
disabled: false,
autofocus: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
}>();
const value = computed({
get: () => props.modelValue,
set: (v: string) => emit('update:modelValue', v.slice(0, props.maxlength)),
});
</script>
<template>
<div class="w-full flex justify-center">
<input
:id="id"
v-model="value"
type="text"
inputmode="numeric"
:maxlength="maxlength"
:disabled="disabled"
:autofocus="autofocus"
class="sr-only"
/>
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex gap-2">
<slot />
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{ index: number }>(), {});
</script>
<template>
<div class="h-10 w-10 rounded-md border border-input bg-background text-center leading-10">
<slot />
</div>
</template>

View File

@@ -0,0 +1,3 @@
export { default as InputOTP } from './InputOTP.vue';
export { default as InputOTPGroup } from './InputOTPGroup.vue';
export { default as InputOTPSlot } from './InputOTPSlot.vue';

View File

@@ -0,0 +1 @@
export { default as Input } from './input.vue';

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
defineProps<{
modelValue?: string | number;
type?: string;
placeholder?: string;
disabled?: boolean;
required?: boolean;
id?: string;
name?: string;
autocomplete?: string;
autofocus?: boolean;
}>();
defineEmits(['update:modelValue']);
</script>
<template>
<input
:id="id"
:name="name"
:type="type || 'text'"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:autocomplete="autocomplete"
:autofocus="autofocus"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
class="flex h-9 w-full rounded-md border border-[#151515] bg-[#0a0a0a] px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-[#888888] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#00f2ff] disabled:cursor-not-allowed disabled:opacity-50 text-white"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Label } from './label.vue';

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
for?: string;
}>();
</script>
<template>
<label :for="for" class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white">
<slot />
</label>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import { ChevronDown, Check } from 'lucide-vue-next';
const props = defineProps<{
modelValue?: string | number;
options?: { label: string; value: string | number; icon?: string }[];
placeholder?: string;
id?: string;
required?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const containerRef = ref<HTMLElement | null>(null);
const toggle = () => {
if (!props.disabled) {
isOpen.value = !isOpen.value;
}
};
const select = (value: string | number) => {
emit('update:modelValue', value);
isOpen.value = false;
};
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.value && !containerRef.value.contains(event.target as Node)) {
isOpen.value = false;
}
};
// Re-init icons when dropdown opens
watch(isOpen, (val) => {
if (val) {
nextTick(() => {
if ((window as any).lucide) (window as any).lucide.createIcons();
});
}
});
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
// Helper to get label for current value
const currentLabel = () => {
if (!props.options) return props.modelValue;
const opt = props.options.find(o => o.value === props.modelValue);
return opt ? opt.label : props.placeholder || 'Select...';
};
// Helper to get icon for current value (optional, to show icon in trigger)
const currentIcon = () => {
if (!props.options) return null;
const opt = props.options.find(o => o.value === props.modelValue);
return opt ? opt.icon : null;
};
// Watch modelValue to update trigger icon
watch(() => props.modelValue, () => {
nextTick(() => {
if ((window as any).lucide) (window as any).lucide.createIcons();
});
});
</script>
<template>
<div class="relative w-full" ref="containerRef">
<!-- Trigger -->
<div
class="flex h-10 w-full items-center justify-between rounded-md border border-[#151515] bg-[#0a0a0a] px-3 py-2 text-sm text-white shadow-sm cursor-pointer transition-all hover:border-[#333]"
:class="{ 'ring-1 ring-[#00f2ff] border-[#00f2ff]': isOpen, 'opacity-50 cursor-not-allowed': disabled }"
@click="toggle"
>
<span class="flex items-center gap-2" :class="{ 'text-[#666]': !modelValue }">
<i v-if="currentIcon()" :data-lucide="currentIcon()" class="w-4 h-4"></i>
{{ currentLabel() }}
</span>
<ChevronDown class="h-4 w-4 text-[#666] transition-transform duration-200" :class="{ 'rotate-180': isOpen }" />
</div>
<!-- Dropdown -->
<transition name="fade-scale">
<div v-if="isOpen" class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-[#222] bg-[#0a0a0a] py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none custom-scrollbar">
<div v-if="!options || options.length === 0" class="px-4 py-2 text-sm text-[#666]">No options</div>
<div
v-for="opt in options"
:key="opt.value"
class="relative flex cursor-pointer select-none items-center py-2 pl-3 pr-9 text-sm text-[#ccc] hover:bg-[#151515] hover:text-white transition-colors"
:class="{ 'bg-[#111] text-white': modelValue === opt.value }"
@click="select(opt.value)"
>
<span class="flex items-center gap-2 truncate">
<i v-if="opt.icon" :data-lucide="opt.icon" class="w-4 h-4"></i>
{{ opt.label }}
</span>
<span v-if="modelValue === opt.value" class="absolute inset-y-0 right-0 flex items-center pr-4 text-[#00f2ff]">
<Check class="h-4 w-4" />
</span>
</div>
</div>
</transition>
<!-- Hidden Native Select for Form Submission/Validation if needed -->
<select :id="id" :value="modelValue" class="sr-only" :required="required" :disabled="disabled">
<option v-for="opt in options" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</div>
</template>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 6px; }
.custom-scrollbar::-webkit-scrollbar-track { background: #0a0a0a; }
.custom-scrollbar::-webkit-scrollbar-thumb { background: #222; border-radius: 3px; }
.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #333; }
.fade-scale-enter-active, .fade-scale-leave-active { transition: all 0.15s ease-out; }
.fade-scale-enter-from, .fade-scale-leave-to { opacity: 0; transform: scale(0.95) translateY(-5px); }
</style>

View File

@@ -0,0 +1 @@
export { default as Separator } from './Separator.vue';

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
size?: 'sm' | 'md' | 'lg';
}>();
</script>
<template>
<svg
class="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</template>