initial commit
linter / quality (push) Has been cancelled
tests / ci (8.3) (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-09 19:53:31 +02:00
commit c178fab470
307 changed files with 30669 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { AlertCircle } from 'lucide-vue-next';
import { computed } from 'vue';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
type Props = {
errors: string[];
title?: string;
};
const props = withDefaults(defineProps<Props>(), {
title: 'Something went wrong.',
});
const uniqueErrors = computed(() => Array.from(new Set(props.errors)));
</script>
<template>
<Alert variant="destructive">
<AlertCircle class="size-4" />
<AlertTitle>{{ title }}</AlertTitle>
<AlertDescription>
<ul class="list-inside list-disc text-sm">
<li v-for="(error, index) in uniqueErrors" :key="index">
{{ error }}
</li>
</ul>
</AlertDescription>
</Alert>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue';
import { SidebarInset } from '@/components/ui/sidebar';
import type { AppVariant } from '@/types';
type Props = {
variant?: AppVariant;
class?: string;
};
const props = withDefaults(defineProps<Props>(), {
variant: 'sidebar',
});
const className = computed(() => props.class);
</script>
<template>
<SidebarInset v-if="props.variant === 'sidebar'" :class="className">
<slot />
</SidebarInset>
<main
v-else
class="mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl"
:class="className"
>
<slot />
</main>
</template>
+283
View File
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { BookOpen, Folder, LayoutGrid, Menu, Search } from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import AppLogoIcon from '@/components/AppLogoIcon.vue';
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import UserMenuContent from '@/components/UserMenuContent.vue';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import { getInitials } from '@/composables/useInitials';
import { toUrl } from '@/lib/utils';
import { dashboard } from '@/routes';
import type { BreadcrumbItem, NavItem } from '@/types';
type Props = {
breadcrumbs?: BreadcrumbItem[];
};
const props = withDefaults(defineProps<Props>(), {
breadcrumbs: () => [],
});
const page = usePage();
const auth = computed(() => page.props.auth);
const { isCurrentUrl, whenCurrentUrl } = useCurrentUrl();
const activeItemStyles =
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
const mainNavItems: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
},
];
const rightNavItems: NavItem[] = [
{
title: 'Repository',
href: 'https://github.com/laravel/vue-starter-kit',
icon: Folder,
},
{
title: 'Documentation',
href: 'https://laravel.com/docs/starter-kits#vue',
icon: BookOpen,
},
];
</script>
<template>
<div>
<div class="border-b border-sidebar-border/80">
<div class="mx-auto flex h-16 items-center px-4 md:max-w-7xl">
<!-- Mobile Menu -->
<div class="lg:hidden">
<Sheet>
<SheetTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="mr-2 h-9 w-9"
>
<Menu class="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" class="w-[300px] p-6">
<SheetTitle class="sr-only"
>Navigation menu</SheetTitle
>
<SheetHeader class="flex justify-start text-left">
<AppLogoIcon
class="size-6 fill-current text-black dark:text-white"
/>
</SheetHeader>
<div
class="flex h-full flex-1 flex-col justify-between space-y-4 py-6"
>
<nav class="-mx-3 space-y-1">
<Link
v-for="item in mainNavItems"
:key="item.title"
:href="item.href"
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent"
:class="
whenCurrentUrl(
item.href,
activeItemStyles,
)
"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
{{ item.title }}
</Link>
</nav>
<div class="flex flex-col space-y-4">
<a
v-for="item in rightNavItems"
:key="item.title"
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
class="flex items-center space-x-2 text-sm font-medium"
>
<component
v-if="item.icon"
:is="item.icon"
class="h-5 w-5"
/>
<span>{{ item.title }}</span>
</a>
</div>
</div>
</SheetContent>
</Sheet>
</div>
<Link :href="dashboard()" class="flex items-center gap-x-2">
<AppLogo />
</Link>
<!-- Desktop Menu -->
<div class="hidden h-full lg:flex lg:flex-1">
<NavigationMenu class="ml-10 flex h-full items-stretch">
<NavigationMenuList
class="flex h-full items-stretch space-x-2"
>
<NavigationMenuItem
v-for="(item, index) in mainNavItems"
:key="index"
class="relative flex h-full items-center"
>
<Link
:class="[
navigationMenuTriggerStyle(),
whenCurrentUrl(
item.href,
activeItemStyles,
),
'h-9 cursor-pointer px-3',
]"
:href="item.href"
>
<component
v-if="item.icon"
:is="item.icon"
class="mr-2 h-4 w-4"
/>
{{ item.title }}
</Link>
<div
v-if="isCurrentUrl(item.href)"
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
></div>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<div class="ml-auto flex items-center space-x-2">
<div class="relative flex items-center space-x-1">
<Button
variant="ghost"
size="icon"
class="group h-9 w-9 cursor-pointer"
>
<Search
class="size-5 opacity-80 group-hover:opacity-100"
/>
</Button>
<div class="hidden space-x-1 lg:flex">
<template
v-for="item in rightNavItems"
:key="item.title"
>
<TooltipProvider :delay-duration="0">
<Tooltip>
<TooltipTrigger>
<Button
variant="ghost"
size="icon"
as-child
class="group h-9 w-9 cursor-pointer"
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<span class="sr-only">{{
item.title
}}</span>
<component
:is="item.icon"
class="size-5 opacity-80 group-hover:opacity-100"
/>
</a>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{{ item.title }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger :as-child="true">
<Button
variant="ghost"
size="icon"
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary"
>
<Avatar
class="size-8 overflow-hidden rounded-full"
>
<AvatarImage
v-if="auth.user.avatar"
:src="auth.user.avatar"
:alt="auth.user.name"
/>
<AvatarFallback
class="rounded-lg bg-neutral-200 font-semibold text-black dark:bg-neutral-700 dark:text-white"
>
{{ getInitials(auth.user?.name) }}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-56">
<UserMenuContent :user="auth.user" />
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
<div
v-if="props.breadcrumbs.length > 1"
class="flex w-full border-b border-sidebar-border/70"
>
<div
class="mx-auto flex h-12 w-full items-center justify-start px-4 text-neutral-500 md:max-w-7xl"
>
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</div>
</div>
</div>
</template>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import AppLogoIcon from '@/components/AppLogoIcon.vue';
</script>
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md bg-sidebar-primary text-sidebar-primary-foreground"
>
<AppLogoIcon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
<span class="mb-0.5 truncate leading-tight font-semibold"
>Laravel Starter Kit</span
>
</div>
</template>
+29
View File
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue';
defineOptions({
inheritAttrs: false,
});
type Props = {
className?: HTMLAttributes['class'];
};
defineProps<Props>();
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 42"
:class="className"
v-bind="$attrs"
>
<path
fill="currentColor"
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
/>
</svg>
</template>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { SidebarProvider } from '@/components/ui/sidebar';
import type { AppVariant } from '@/types';
type Props = {
variant?: AppVariant;
};
withDefaults(defineProps<Props>(), {
variant: 'sidebar',
});
const isOpen = usePage().props.sidebarOpen;
</script>
<template>
<div v-if="variant === 'header'" class="flex min-h-screen w-full flex-col">
<slot />
</div>
<SidebarProvider v-else :default-open="isOpen">
<slot />
</SidebarProvider>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { BookOpen, FolderGit2, LayoutGrid, Gift, Settings, ShieldCheck, Users } from 'lucide-vue-next';
import { computed } from 'vue';
import AppLogo from '@/components/AppLogo.vue';
import NavFooter from '@/components/NavFooter.vue';
import NavMain from '@/components/NavMain.vue';
import NavUser from '@/components/NavUser.vue';
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import type { NavItem } from '@/types';
const page = usePage();
const userRole = computed(() => {
return (page.props.auth.user as any)?.role || 'user';
});
const isModOrAdmin = computed(() => {
return ['admin', 'mod'].includes(userRole.value);
});
const isAdmin = computed(() => {
return userRole.value === 'admin';
});
const mainNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [
{
title: 'Dashboard',
href: dashboard(),
icon: LayoutGrid,
}
];
if (isModOrAdmin.value) {
items.push({
title: 'Bonuses',
href: '/admin/bonuses',
icon: Gift,
});
}
if (isAdmin.value) {
items.push({
title: 'Benutzer',
href: '/admin/users',
icon: Users,
});
}
return items;
});
const footerNavItems = computed<NavItem[]>(() => {
const items: NavItem[] = [];
if (isAdmin.value) {
items.push({
title: 'System Settings',
href: '/admin/settings',
icon: ShieldCheck,
});
}
return items;
});
</script>
<template>
<Sidebar collapsible="icon" variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" as-child>
<Link :href="dashboard()">
<AppLogo />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain :items="mainNavItems" />
</SidebarContent>
<SidebarFooter>
<NavFooter v-if="footerNavItems.length > 0" :items="footerNavItems" />
<NavUser />
</SidebarFooter>
</Sidebar>
<slot />
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import Breadcrumbs from '@/components/Breadcrumbs.vue';
import { SidebarTrigger } from '@/components/ui/sidebar';
import type { BreadcrumbItem } from '@/types';
withDefaults(
defineProps<{
breadcrumbs?: BreadcrumbItem[];
}>(),
{
breadcrumbs: () => [],
},
);
</script>
<template>
<header
class="flex h-16 shrink-0 items-center gap-2 border-b border-sidebar-border/70 px-6 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:px-4"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<template v-if="breadcrumbs && breadcrumbs.length > 0">
<Breadcrumbs :breadcrumbs="breadcrumbs" />
</template>
</div>
</header>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Monitor, Moon, Sun } from 'lucide-vue-next';
import { useAppearance } from '@/composables/useAppearance';
const { appearance, updateAppearance } = useAppearance();
const tabs = [
{ value: 'light', Icon: Sun, label: 'Light' },
{ value: 'dark', Icon: Moon, label: 'Dark' },
{ value: 'system', Icon: Monitor, label: 'System' },
] as const;
</script>
<template>
<div
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
>
<button
v-for="{ value, Icon, label } in tabs"
:key="value"
@click="updateAppearance(value)"
:class="[
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
]"
>
<component :is="Icon" class="-ml-1 h-4 w-4" />
<span class="ml-1.5 text-sm">{{ label }}</span>
</button>
</div>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
type Props = {
breadcrumbs: BreadcrumbItemType[];
};
defineProps<Props>();
</script>
<template>
<Breadcrumb>
<BreadcrumbList>
<template v-for="(item, index) in breadcrumbs" :key="index">
<BreadcrumbItem>
<template v-if="index === breadcrumbs.length - 1">
<BreadcrumbPage>{{ item.title }}</BreadcrumbPage>
</template>
<template v-else>
<BreadcrumbLink as-child>
<Link :href="item.href">{{ item.title }}</Link>
</BreadcrumbLink>
</template>
</BreadcrumbItem>
<BreadcrumbSeparator v-if="index !== breadcrumbs.length - 1" />
</template>
</BreadcrumbList>
</Breadcrumb>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import Heading from '@/components/Heading.vue';
import InputError from '@/components/InputError.vue';
import PasswordInput from '@/components/PasswordInput.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
const passwordInput = useTemplateRef('passwordInput');
</script>
<template>
<div class="space-y-6">
<Heading
variant="small"
title="Delete account"
description="Delete your account and all of its resources"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p>
<p class="text-sm">
Please proceed with caution, this cannot be undone.
</p>
</div>
<Dialog>
<DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button"
>Delete account</Button
>
</DialogTrigger>
<DialogContent>
<Form
v-bind="ProfileController.destroy.form()"
reset-on-success
@error="() => passwordInput?.focus()"
:options="{
preserveScroll: true,
}"
class="space-y-6"
v-slot="{ errors, processing, reset, clearErrors }"
>
<DialogHeader class="space-y-3">
<DialogTitle
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription>
Once your account is deleted, all of its
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription>
</DialogHeader>
<div class="grid gap-2">
<Label for="password" class="sr-only"
>Password</Label
>
<PasswordInput
id="password"
name="password"
ref="passwordInput"
placeholder="Password"
/>
<InputError :message="errors.password" />
</div>
<DialogFooter class="gap-2">
<DialogClose as-child>
<Button
variant="secondary"
@click="
() => {
clearErrors();
reset();
}
"
>
Cancel
</Button>
</DialogClose>
<Button
type="submit"
variant="destructive"
:disabled="processing"
data-test="confirm-delete-user-button"
>
Delete account
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
type Props = {
title: string;
description?: string;
variant?: 'default' | 'small';
};
withDefaults(defineProps<Props>(), {
variant: 'default',
});
</script>
<template>
<header :class="variant === 'small' ? '' : 'mb-8 space-y-0.5'">
<h2
:class="
variant === 'small'
? 'mb-0.5 text-base font-medium'
: 'text-xl font-semibold tracking-tight'
"
>
{{ title }}
</h2>
<p v-if="description" class="text-sm text-muted-foreground">
{{ description }}
</p>
</header>
</template>
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
defineProps<{
message?: string;
}>();
</script>
<template>
<div v-show="message">
<p class="text-sm text-red-600 dark:text-red-500">
{{ message }}
</p>
</div>
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { toUrl } from '@/lib/utils';
import type { NavItem } from '@/types';
type Props = {
items: NavItem[];
class?: string;
};
defineProps<Props>();
</script>
<template>
<SidebarGroup
:class="`group-data-[collapsible=icon]:p-0 ${$props.class || ''}`"
>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
as-child
>
<a
:href="toUrl(item.href)"
target="_blank"
rel="noopener noreferrer"
>
<component :is="item.icon" />
<span>{{ item.title }}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { useCurrentUrl } from '@/composables/useCurrentUrl';
import type { NavItem } from '@/types';
defineProps<{
items: NavItem[];
}>();
const { isCurrentUrl } = useCurrentUrl();
</script>
<template>
<SidebarGroup class="px-2 py-0">
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton
as-child
:is-active="isCurrentUrl(item.href)"
:tooltip="item.title"
>
<Link :href="item.href">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { ChevronsUpDown } from 'lucide-vue-next';
import { computed } from 'vue';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import UserInfo from '@/components/UserInfo.vue';
import UserMenuContent from '@/components/UserMenuContent.vue';
const page = usePage();
const user = computed(() => page.props.auth.user);
const { isMobile, state } = useSidebar();
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
data-test="sidebar-menu-button"
>
<UserInfo :user="user" />
<ChevronsUpDown class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
:side="
isMobile
? 'bottom'
: state === 'collapsed'
? 'left'
: 'bottom'
"
align="end"
:side-offset="4"
>
<UserMenuContent :user="user" />
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { Eye, EyeOff } from 'lucide-vue-next';
import { ref, useTemplateRef } from 'vue';
import type { HTMLAttributes } from 'vue';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
defineOptions({ inheritAttrs: false });
const props = defineProps<{
class?: HTMLAttributes['class'];
}>();
const showPassword = ref(false);
const inputRef = useTemplateRef('inputRef');
defineExpose({
$el: inputRef,
focus: () => inputRef.value?.$el?.focus(),
});
</script>
<template>
<div class="relative">
<Input
ref="inputRef"
:type="showPassword ? 'text' : 'password'"
:class="cn('pr-10', props.class)"
v-bind="$attrs"
/>
<button
type="button"
@click="showPassword = !showPassword"
:class="
cn(
'absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 text-muted-foreground hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring focus-visible:outline-none',
)
"
:aria-label="showPassword ? 'Hide password' : 'Show password'"
:tabindex="-1"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</button>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useId } from 'vue';
const patternId = `pattern-${useId()}`;
</script>
<template>
<svg
class="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20"
fill="none"
>
<defs>
<pattern
:id="patternId"
x="0"
y="0"
width="8"
height="8"
patternUnits="userSpaceOnUse"
>
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
</pattern>
</defs>
<rect
stroke="none"
:fill="`url(#${patternId})`"
width="100%"
height="100%"
></rect>
</svg>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { LinkComponentBaseProps, Method } from '@inertiajs/core';
import { Link } from '@inertiajs/vue3';
type Props = {
href: LinkComponentBaseProps['href'];
tabindex?: number;
method?: Method;
as?: string;
};
defineProps<Props>();
</script>
<template>
<Link
:href="href"
:tabindex="tabindex"
:method="method"
:as="as"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
>
<slot />
</Link>
</template>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { Eye, EyeOff, LockKeyhole, RefreshCw } from 'lucide-vue-next';
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
import AlertError from '@/components/AlertError.vue';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { regenerateRecoveryCodes } from '@/routes/two-factor';
const { recoveryCodesList, fetchRecoveryCodes, errors } = useTwoFactorAuth();
const isRecoveryCodesVisible = ref<boolean>(false);
const recoveryCodeSectionRef = useTemplateRef('recoveryCodeSectionRef');
const toggleRecoveryCodesVisibility = async () => {
if (!isRecoveryCodesVisible.value && !recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
isRecoveryCodesVisible.value = !isRecoveryCodesVisible.value;
if (isRecoveryCodesVisible.value) {
await nextTick();
recoveryCodeSectionRef.value?.scrollIntoView({ behavior: 'smooth' });
}
};
onMounted(async () => {
if (!recoveryCodesList.value.length) {
await fetchRecoveryCodes();
}
});
</script>
<template>
<Card class="w-full">
<CardHeader>
<CardTitle class="flex gap-3">
<LockKeyhole class="size-4" />2FA recovery codes
</CardTitle>
<CardDescription>
Recovery codes let you regain access if you lose your 2FA
device. Store them in a secure password manager.
</CardDescription>
</CardHeader>
<CardContent>
<div
class="flex flex-col gap-3 select-none sm:flex-row sm:items-center sm:justify-between"
>
<Button @click="toggleRecoveryCodesVisibility" class="w-fit">
<component
:is="isRecoveryCodesVisible ? EyeOff : Eye"
class="size-4"
/>
{{ isRecoveryCodesVisible ? 'Hide' : 'View' }} recovery
codes
</Button>
<Form
v-if="isRecoveryCodesVisible && recoveryCodesList.length"
v-bind="regenerateRecoveryCodes.form()"
method="post"
:options="{ preserveScroll: true }"
@success="fetchRecoveryCodes"
#default="{ processing }"
>
<Button
variant="secondary"
type="submit"
:disabled="processing"
>
<RefreshCw /> Regenerate codes
</Button>
</Form>
</div>
<div
:class="[
'relative overflow-hidden transition-all duration-300',
isRecoveryCodesVisible
? 'h-auto opacity-100'
: 'h-0 opacity-0',
]"
>
<div v-if="errors?.length" class="mt-6">
<AlertError :errors="errors" />
</div>
<div v-else class="mt-3 space-y-3">
<div
ref="recoveryCodeSectionRef"
class="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
>
<div v-if="!recoveryCodesList.length" class="space-y-2">
<div
v-for="n in 8"
:key="n"
class="h-4 animate-pulse rounded bg-muted-foreground/20"
></div>
</div>
<div
v-else
v-for="(code, index) in recoveryCodesList"
:key="index"
>
{{ code }}
</div>
</div>
<p class="text-xs text-muted-foreground select-none">
Each recovery code can be used once to access your
account and will be removed after use. If you need more,
click
<span class="font-bold">Regenerate codes</span> above.
</p>
</div>
</div>
</CardContent>
</Card>
</template>
@@ -0,0 +1,297 @@
<script setup lang="ts">
import { Form } from '@inertiajs/vue3';
import { useClipboard } from '@vueuse/core';
import { Check, Copy, ScanLine } from 'lucide-vue-next';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AlertError from '@/components/AlertError.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { Spinner } from '@/components/ui/spinner';
import { useAppearance } from '@/composables/useAppearance';
import { useTwoFactorAuth } from '@/composables/useTwoFactorAuth';
import { confirm } from '@/routes/two-factor';
import type { TwoFactorConfigContent } from '@/types';
type Props = {
requiresConfirmation: boolean;
twoFactorEnabled: boolean;
};
const { resolvedAppearance } = useAppearance();
const props = defineProps<Props>();
const isOpen = defineModel<boolean>('isOpen');
const { copy, copied } = useClipboard();
const { qrCodeSvg, manualSetupKey, clearSetupData, fetchSetupData, errors } =
useTwoFactorAuth();
const showVerificationStep = ref(false);
const code = ref<string>('');
const pinInputContainerRef = useTemplateRef('pinInputContainerRef');
const modalConfig = computed<TwoFactorConfigContent>(() => {
if (props.twoFactorEnabled) {
return {
title: 'Two-factor authentication enabled',
description:
'Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.',
buttonText: 'Close',
};
}
if (showVerificationStep.value) {
return {
title: 'Verify authentication code',
description: 'Enter the 6-digit code from your authenticator app',
buttonText: 'Continue',
};
}
return {
title: 'Enable two-factor authentication',
description:
'To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app',
buttonText: 'Continue',
};
});
const handleModalNextStep = () => {
if (props.requiresConfirmation) {
showVerificationStep.value = true;
nextTick(() => {
pinInputContainerRef.value?.querySelector('input')?.focus();
});
return;
}
clearSetupData();
isOpen.value = false;
};
const resetModalState = () => {
if (props.twoFactorEnabled) {
clearSetupData();
}
showVerificationStep.value = false;
code.value = '';
};
watch(
() => isOpen.value,
async (isOpen) => {
if (!isOpen) {
resetModalState();
return;
}
if (!qrCodeSvg.value) {
await fetchSetupData();
}
},
);
</script>
<template>
<Dialog :open="isOpen" @update:open="isOpen = $event">
<DialogContent class="sm:max-w-md">
<DialogHeader class="flex items-center justify-center">
<div
class="mb-3 w-auto rounded-full border border-border bg-card p-0.5 shadow-sm"
>
<div
class="relative overflow-hidden rounded-full border border-border bg-muted p-2.5"
>
<div
class="absolute inset-0 grid grid-cols-5 opacity-50"
>
<div
v-for="i in 5"
:key="`col-${i}`"
class="border-r border-border last:border-r-0"
/>
</div>
<div
class="absolute inset-0 grid grid-rows-5 opacity-50"
>
<div
v-for="i in 5"
:key="`row-${i}`"
class="border-b border-border last:border-b-0"
/>
</div>
<ScanLine
class="relative z-20 size-6 text-foreground"
/>
</div>
</div>
<DialogTitle>{{ modalConfig.title }}</DialogTitle>
<DialogDescription class="text-center">
{{ modalConfig.description }}
</DialogDescription>
</DialogHeader>
<div
class="relative flex w-auto flex-col items-center justify-center space-y-5"
>
<template v-if="!showVerificationStep">
<AlertError v-if="errors?.length" :errors="errors" />
<template v-else>
<div
class="relative mx-auto flex max-w-md items-center overflow-hidden"
>
<div
class="relative mx-auto aspect-square w-64 overflow-hidden rounded-lg border border-border"
>
<div
v-if="!qrCodeSvg"
class="absolute inset-0 z-10 flex aspect-square h-auto w-full animate-pulse items-center justify-center bg-background"
>
<Spinner class="size-6" />
</div>
<div
v-else
class="relative z-10 overflow-hidden border p-5"
>
<div
v-html="qrCodeSvg"
class="flex aspect-square size-full items-center justify-center"
:style="{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}"
/>
</div>
</div>
</div>
<div class="flex w-full items-center space-x-5">
<Button class="w-full" @click="handleModalNextStep">
{{ modalConfig.buttonText }}
</Button>
</div>
<div
class="relative flex w-full items-center justify-center"
>
<div
class="absolute inset-0 top-1/2 h-px w-full bg-border"
/>
<span class="relative bg-card px-2 py-1"
>or, enter the code manually</span
>
</div>
<div
class="flex w-full items-center justify-center space-x-2"
>
<div
class="flex w-full items-stretch overflow-hidden rounded-xl border border-border"
>
<div
v-if="!manualSetupKey"
class="flex h-full w-full items-center justify-center bg-muted p-3"
>
<Spinner />
</div>
<template v-else>
<input
type="text"
readonly
:value="manualSetupKey"
class="h-full w-full bg-background p-3 text-foreground"
/>
<button
@click="copy(manualSetupKey || '')"
class="relative block h-auto border-l border-border px-3 hover:bg-muted"
>
<Check
v-if="copied"
class="w-4 text-green-500"
/>
<Copy v-else class="w-4" />
</button>
</template>
</div>
</div>
</template>
</template>
<template v-else>
<Form
v-bind="confirm.form()"
error-bag="confirmTwoFactorAuthentication"
reset-on-error
@finish="code = ''"
@success="isOpen = false"
v-slot="{ errors, processing }"
>
<input type="hidden" name="code" :value="code" />
<div
ref="pinInputContainerRef"
class="relative w-full space-y-3"
>
<div
class="flex w-full flex-col items-center justify-center space-y-3 py-2"
>
<InputOTP
id="otp"
v-model="code"
:maxlength="6"
:disabled="processing"
>
<InputOTPGroup>
<InputOTPSlot
v-for="index in 6"
:key="index"
:index="index - 1"
/>
</InputOTPGroup>
</InputOTP>
<InputError :message="errors?.code" />
</div>
<div class="flex w-full items-center space-x-5">
<Button
type="button"
variant="outline"
class="w-auto flex-1"
@click="showVerificationStep = false"
:disabled="processing"
>
Back
</Button>
<Button
type="submit"
class="w-auto flex-1"
:disabled="processing || code.length < 6"
>
Confirm
</Button>
</div>
</div>
</Form>
</template>
</div>
</DialogContent>
</Dialog>
</template>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useInitials } from '@/composables/useInitials';
import type { User } from '@/types';
type Props = {
user: User;
showEmail?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
showEmail: false,
});
const { getInitials } = useInitials();
// Compute whether we should show the avatar image
const showAvatar = computed(
() => props.user.avatar && props.user.avatar !== '',
);
</script>
<template>
<Avatar class="h-8 w-8 overflow-hidden rounded-lg">
<AvatarImage v-if="showAvatar" :src="user.avatar!" :alt="user.name" />
<AvatarFallback class="rounded-lg text-black dark:text-white">
{{ getInitials(user.name) }}
</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs text-muted-foreground">{{
user.email
}}</span>
</div>
</template>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { LogOut, Settings } from 'lucide-vue-next';
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import { logout } from '@/routes';
import { edit } from '@/routes/profile';
import type { User } from '@/types';
type Props = {
user: User;
};
const handleLogout = () => {
router.flushAll();
};
defineProps<Props>();
</script>
<template>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo :user="user" :show-email="true" />
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem :as-child="true">
<Link class="block w-full cursor-pointer" :href="edit()" prefetch>
<Settings class="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem :as-child="true">
<Link
class="block w-full cursor-pointer"
:href="logout()"
@click="handleLogout"
as="button"
data-test="logout-button"
>
<LogOut class="mr-2 h-4 w-4" />
Log out
</Link>
</DropdownMenuItem>
</template>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
interface Star {
x: number;
y: number;
size: number;
opacity: number;
speed: number;
}
const starCanvasRef = ref<HTMLCanvasElement | null>(null);
let backgroundStars: Star[] = [];
let animationFrameId: number;
function initBackgroundStars() {
if (typeof window === 'undefined' || !starCanvasRef.value) return;
const canvas = starCanvasRef.value;
// Explizite Zuweisung der Fenstergröße an die Canvas-Attribute
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
backgroundStars = [];
const count = Math.min(window.innerWidth / 3, 400); // Etwas mehr Sterne für bessere Sichtbarkeit
for (let i = 0; i < count; i++) {
backgroundStars.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: Math.random() * 1.5 + 0.5,
opacity: Math.random(),
speed: Math.random() * 0.15 + 0.05,
});
}
}
function drawBackgroundStars() {
if (typeof window === 'undefined') return;
const canvas = starCanvasRef.value;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let i = 0; i < backgroundStars.length; i++) {
const s = backgroundStars[i];
s.opacity += (Math.random() - 0.5) * 0.03;
if (s.opacity <= 0.1) s.opacity = 0.1;
if (s.opacity >= 0.8) s.opacity = 0.8;
ctx.fillStyle = `rgba(255, 255, 255, ${s.opacity})`;
ctx.beginPath();
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2);
ctx.fill();
s.y -= s.speed;
if (s.y < -10) {
s.y = canvas.height + 10;
s.x = Math.random() * canvas.width;
}
}
animationFrameId = requestAnimationFrame(drawBackgroundStars);
}
const handleResize = () => {
initBackgroundStars();
};
onMounted(() => {
initBackgroundStars();
drawBackgroundStars();
window.addEventListener('resize', handleResize);
});
onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('resize', handleResize);
}
if (animationFrameId) cancelAnimationFrame(animationFrameId);
});
</script>
<template>
<div
class="pointer-events-none fixed inset-0 h-full w-full"
style="z-index: 0"
>
<div class="absolute inset-0 bg-[#020617]"></div>
<canvas
ref="starCanvasRef"
class="absolute inset-0 block h-full w-full"
></canvas>
<div
class="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,#020617_100%)] opacity-60"
></div>
</div>
</template>
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
interface Badge {
label: string;
class: string;
}
interface Bonus {
id: number;
name: string;
description: string;
image_path: string;
brand_color: string;
hover_color: string;
badges: Badge[];
min_deposit: string;
max_bet: string;
wagering: string;
free_spins: string;
button_link: string;
key_features: string[];
is_sticky: boolean;
is_no_deposit: boolean;
is_featured: boolean;
}
const props = defineProps<{
bonuses?: Bonus[];
}>();
const bonusGridRef = ref<HTMLElement | null>(null);
const handleMouseMoveGrid = (e: MouseEvent) => {
if (!bonusGridRef.value) return;
const gridRect = bonusGridRef.value.getBoundingClientRect();
const mx = e.clientX - gridRect.left;
const my = e.clientY - gridRect.top;
bonusGridRef.value.style.setProperty('--mouse-x', `${mx}px`);
bonusGridRef.value.style.setProperty('--mouse-y', `${my}px`);
const cards = bonusGridRef.value.querySelectorAll('.bonus-card') as NodeListOf<HTMLElement>;
cards.forEach(card => {
card.style.setProperty('--card-left', `${card.offsetLeft}`);
card.style.setProperty('--card-top', `${card.offsetTop}`);
});
};
const trackClick = async (bonusId: number) => {
try {
await fetch('/api/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
},
body: JSON.stringify({
bonus_id: bonusId,
type: 'click'
})
});
} catch (e) {
console.error('Tracking failed', e);
}
};
const trackView = async (bonusId: number) => {
try {
await fetch('/api/track', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
},
body: JSON.stringify({
bonus_id: bonusId,
type: 'view'
})
});
} catch (e) {
console.error('Tracking failed', e);
}
};
onMounted(() => {
if (bonusGridRef.value) {
bonusGridRef.value.addEventListener('mousemove', handleMouseMoveGrid);
}
if (props.bonuses && props.bonuses.length > 0) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const card = entry.target as HTMLElement;
const bonusId = card.dataset.id;
if (bonusId && !card.dataset.viewed) {
trackView(Number(bonusId));
card.dataset.viewed = 'true';
}
}
});
}, { threshold: 0.5 });
setTimeout(() => {
if (bonusGridRef.value) {
const cards = bonusGridRef.value.querySelectorAll('.bonus-card');
cards.forEach(card => observer.observe(card));
}
}, 100);
}
});
onUnmounted(() => {
if (bonusGridRef.value) {
bonusGridRef.value.removeEventListener('mousemove', handleMouseMoveGrid);
}
});
</script>
<template>
<section id="bonuses" class="container mx-auto px-6 py-28 relative z-30">
<h2 class="text-5xl font-black italic text-center mb-20 uppercase tracking-tighter">Premium <span class="text-blue-500">Deals</span></h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 mb-16 items-end bg-gray-950/50 p-6 rounded-3xl border border-white/5 backdrop-blur-sm">
<div class="xl:col-span-2 relative">
<label class="text-xs text-gray-500 mb-2 block uppercase font-bold tracking-wider">Schnellsuche</label>
<div class="relative">
<i class="fas fa-search absolute left-5 top-1/2 -translate-y-1/2 text-gray-600"></i>
<input type="text" placeholder="Casino Name oder Bonusart..." class="w-full bg-white/5 border border-white/10 rounded-xl py-4 pl-14 outline-none focus:border-purple-500 transition focus:ring-1 focus:ring-purple-500 text-white placeholder-gray-500">
</div>
</div>
<div class="relative min-w-[200px] group">
<label class="text-xs text-gray-500 mb-2 block uppercase font-bold tracking-wider">Land</label>
<div class="bg-white/5 border border-white/10 p-[14px_20px] rounded-xl cursor-pointer flex justify-between items-center transition group-hover:border-white/30 text-white relative z-10">
<span>🇩🇪 Germany</span>
<i class="fas fa-chevron-down opacity-50 text-xs transition-transform"></i>
</div>
<div class="absolute top-[115%] left-0 right-0 bg-[#111827] border border-white/10 rounded-xl hidden group-hover:block z-50 overflow-hidden shadow-[0_10px_25px_rgba(0,0,0,0.5)]">
<div class="p-[12px_20px] cursor-pointer transition hover:bg-white/5">🇩🇪 Germany</div>
<div class="p-[12px_20px] cursor-pointer transition hover:bg-white/5">🇦🇹 Austria</div>
<div class="p-[12px_20px] cursor-pointer transition hover:bg-white/5">🇨🇭 Switzerland</div>
</div>
</div>
<div class="relative min-w-[200px] group">
<label class="text-xs text-gray-500 mb-2 block uppercase font-bold tracking-wider">Methode</label>
<div class="bg-white/5 border border-white/10 p-[14px_20px] rounded-xl cursor-pointer flex justify-between items-center transition group-hover:border-white/30 text-white relative z-10">
<span><i class="fas fa-wallet text-blue-400 mr-2"></i> Hybrid</span>
<i class="fas fa-chevron-down opacity-50 text-xs transition-transform"></i>
</div>
<div class="absolute top-[115%] left-0 right-0 bg-[#111827] border border-white/10 rounded-xl hidden group-hover:block z-50 overflow-hidden shadow-[0_10px_25px_rgba(0,0,0,0.5)]">
<div class="p-[12px_20px] cursor-pointer transition hover:bg-white/5"><i class="fas fa-wallet text-blue-400 mr-2"></i> Hybrid</div>
<div class="p-[12px_20px] cursor-pointer transition hover:bg-white/5"><i class="fab fa-bitcoin text-orange-400 mr-2"></i> Crypto</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10 bonus-grid relative" ref="bonusGridRef">
<div v-for="bonus in bonuses" :key="bonus.id"
:data-id="bonus.id"
:class="[
'bonus-card relative bg-[#111827] rounded-[28px] border overflow-hidden transition-transform duration-400 hover:-translate-y-2 hover:scale-[1.01] h-full z-[1] flex flex-col group',
bonus.is_featured ? 'border-yellow-500/50 shadow-lg shadow-yellow-500/20' : 'border-white/5'
]"
:style="{ '--spot-clr': bonus.hover_color || 'rgba(255,255,255,0.1)' }">
<img v-if="bonus.image_path" :src="bonus.image_path" class="absolute inset-0 w-full h-full object-cover opacity-15 z-[-1] transition-opacity duration-300 group-hover:opacity-25" :alt="bonus.name">
<div class="spotlight"></div>
<div class="p-8 relative z-10 flex flex-col h-full">
<h3 class="text-4xl font-black italic tracking-tighter mb-2" :style="{ color: bonus.brand_color || '#ffffff' }">
{{ bonus.name }}
</h3>
<p class="text-gray-300 text-sm leading-relaxed mb-6">{{ bonus.description }}</p>
<div class="flex flex-wrap gap-2 mb-6">
<span v-if="bonus.is_sticky" class="px-3 py-1 bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-full text-[10px] font-bold uppercase tracking-wider">Sticky</span>
<span v-if="bonus.is_no_deposit" class="px-3 py-1 bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 rounded-full text-[10px] font-bold uppercase tracking-wider">No Deposit</span>
<span v-if="bonus.is_featured" class="px-3 py-1 bg-yellow-500/20 text-yellow-300 border border-yellow-500/30 rounded-full text-[10px] font-bold uppercase tracking-wider">Featured</span>
<span v-for="(badge, index) in bonus.badges" :key="index"
class="px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-white/10 text-white border border-white/20">
{{ typeof badge === 'string' ? badge : badge.label }}
</span>
</div>
<ul v-if="bonus.key_features && bonus.key_features.length" class="mb-8 space-y-1">
<li v-for="feature in bonus.key_features" :key="feature" class="text-[11px] text-gray-400 flex items-center">
<i class="fas fa-check text-green-500 mr-2 text-[9px]"></i> {{ feature }}
</li>
</ul>
<div class="mt-auto space-y-3 text-xs text-gray-400 border-t border-white/10 pt-6 mb-8">
<div class="flex justify-between"><span>Min Deposit</span><span class="text-white font-bold">{{ bonus.min_deposit || 'N/A' }}</span></div>
<div class="flex justify-between"><span>Wagering</span><span class="text-white font-bold">{{ bonus.wagering || 'N/A' }}</span></div>
<div class="flex justify-between"><span>Max Bet</span><span class="text-white font-bold">{{ bonus.max_bet || 'N/A' }}</span></div>
<div v-if="bonus.free_spins" class="flex justify-between"><span>Free Spins</span><span class="text-white font-bold">{{ bonus.free_spins }}</span></div>
</div>
<div class="flex gap-3">
<a :href="bonus.button_link" target="_blank" @click="trackClick(bonus.id)"
class="grow bg-white text-black font-black py-4 rounded-xl hover:text-white transition uppercase tracking-tighter cursor-pointer text-center"
:style="{ '--hover-bg': bonus.brand_color || '#a855f7' }">
Deal Sichern
</a>
<button class="bg-white/5 border border-white/10 hover:bg-white/15 hover:border-white transition-all duration-300 px-5 rounded-xl text-white cursor-pointer" title="Mehr Infos"><i class="fas fa-info"></i></button>
</div>
</div>
</div>
<div v-if="!bonuses || bonuses.length === 0" class="col-span-full text-center py-20 text-gray-500">
Keine Deals gefunden.
</div>
</div>
</section>
</template>
<style scoped>
.bonus-grid {
--mouse-x: -1000px;
--mouse-y: -1000px;
}
.bonus-card {
--spot-clr: rgba(255,255,255,0.1);
}
.spotlight {
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(circle, var(--spot-clr) 0%, transparent 70%);
pointer-events: none;
mix-blend-mode: screen;
z-index: 0;
opacity: 0;
left: calc(var(--mouse-x) - (var(--card-left, 0) * 1px));
top: calc(var(--mouse-y) - (var(--card-top, 0) * 1px));
transform: translate(-50%, -50%);
transition: opacity 0.3s ease;
}
.bonus-grid:hover .spotlight {
opacity: 0.35;
}
.bonus-card::before {
content: "";
position: absolute;
inset: 0;
border-radius: 28px;
padding: 2px;
background: radial-gradient(
350px circle at calc(var(--mouse-x) - var(--card-left, 0) * 1px) calc(var(--mouse-y) - var(--card-top, 0) * 1px),
var(--spot-clr),
transparent 80%
);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
z-index: 5;
opacity: 0;
transition: opacity 0.3s;
}
.bonus-grid:hover .bonus-card::before {
opacity: 1;
}
a[style*="--hover-bg"]:hover {
background-color: var(--hover-bg) !important;
}
</style>
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
const heroCanvasRef = ref<HTMLCanvasElement | null>(null);
let particles: Particle[] = [];
const mouse = { x: -1000, y: -1000, radius: 120 };
let animationFrameIdHero: number;
const handleGlobalMouseMove = (e: MouseEvent) => {
mouse.x = e.clientX;
mouse.y = e.clientY;
};
class Particle {
baseX: number;
baseY: number;
x: number;
y: number;
size: number;
isWhite: boolean;
blinkTimer: number;
opacity: number;
ease: number;
constructor(
x: number,
y: number,
private canvasWidth: number,
private canvasHeight: number,
) {
this.baseX = x;
this.baseY = y;
this.x = Math.random() * canvasWidth;
this.y = Math.random() * canvasHeight;
this.size = 1.6;
this.isWhite = false;
this.blinkTimer = 0;
this.opacity = 1;
this.ease = 0.06;
}
draw(ctx: CanvasRenderingContext2D) {
if (this.isWhite) {
this.opacity = Math.sin(this.blinkTimer) * 0.5 + 0.5;
ctx.fillStyle = `rgba(255, 255, 255, ${Math.max(0.3, this.opacity)})`;
this.blinkTimer += 0.1;
if (this.blinkTimer > Math.PI) {
this.isWhite = false;
this.opacity = 1;
}
} else {
const ratio = this.x / this.canvasWidth;
const r = 168 - (168 - 59) * ratio;
const g = 85 + (130 - 85) * ratio;
ctx.fillStyle = `rgb(${r},${g},255)`;
}
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
}
update() {
let dx = mouse.x - this.x;
let dy = mouse.y - this.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouse.radius) {
const force = (mouse.radius - distance) / mouse.radius;
this.x -= (dx / distance) * force * 7;
this.y -= (dy / distance) * force * 7;
} else {
this.x += (this.baseX - this.x) * this.ease;
this.y += (this.baseY - this.y) * this.ease;
}
}
}
function initHero() {
if (!heroCanvasRef.value) return;
const canvas = heroCanvasRef.value;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const tCanvas = document.createElement('canvas');
const tCtx = tCanvas.getContext('2d');
if (!tCtx) return;
tCanvas.width = canvas.width;
tCanvas.height = canvas.height;
// Wir verschieben den Text im Partikel-Canvas etwas nach unten,
// um Platz für das "Welcome" darüber zu schaffen
const fontSize = Math.min(canvas.width / 9, 90);
tCtx.fillStyle = 'white';
tCtx.font = `bold ${fontSize}px Arial`;
tCtx.textAlign = 'center';
tCtx.fillText(
'BRATANBONUS.NET',
tCanvas.width / 2,
tCanvas.height / 2 + 40,
);
const pixels = tCtx.getImageData(0, 0, tCanvas.width, tCanvas.height);
particles = [];
const step = 5;
for (let y = 0; y < pixels.height; y += step) {
for (let x = 0; x < pixels.width; x += step) {
if (pixels.data[y * 4 * pixels.width + x * 4 + 3] > 128) {
particles.push(new Particle(x, y, canvas.width, canvas.height));
}
}
}
}
function animateHero() {
if (!heroCanvasRef.value) return;
const ctx = heroCanvasRef.value.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, heroCanvasRef.value.width, heroCanvasRef.value.height);
if (Math.random() > 0.93 && particles.length > 0) {
const p = particles[Math.floor(Math.random() * particles.length)];
if (!p && !p.isWhite) {
p.isWhite = true;
p.blinkTimer = 0;
}
}
particles.forEach((p) => {
p.update();
p.draw(ctx);
});
animationFrameIdHero = requestAnimationFrame(animateHero);
}
const handleResize = () => {
initHero();
};
onMounted(() => {
window.addEventListener('mousemove', handleGlobalMouseMove);
window.addEventListener('resize', handleResize);
setTimeout(() => {
initHero();
animateHero();
}, 100);
});
onUnmounted(() => {
window.removeEventListener('mousemove', handleGlobalMouseMove);
window.removeEventListener('resize', handleResize);
if (animationFrameIdHero) cancelAnimationFrame(animationFrameIdHero);
});
</script>
<template>
<section
class="relative flex h-screen flex-col items-center justify-center overflow-hidden bg-transparent"
>
<div
class="pointer-events-none absolute top-[32%] left-1/2 z-10 w-full -translate-x-1/2 text-center"
>
<h2
class="text-2xl font-thin tracking-[1em] uppercase opacity-30 select-none md:text-4xl"
>
Welcome
</h2>
</div>
<canvas
ref="heroCanvasRef"
class="pointer-events-none absolute inset-0 z-20 block h-screen w-full bg-transparent"
></canvas>
<a
href="#bonuses"
class="group absolute bottom-10 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-2 opacity-50 transition-all duration-300 hover:opacity-100"
>
<span
class="text-[10px] font-bold tracking-[0.3em] text-white/40 uppercase group-hover:text-white/80"
>Scroll</span
>
<div
class="flex h-10 w-6 justify-center rounded-full border-2 border-white/20 p-1"
>
<div
class="animate-scroll-dot h-2 w-1 rounded-full bg-purple-500"
></div>
</div>
<i
class="fas fa-chevron-down mt-1 animate-bounce text-sm text-white/30"
></i>
</a>
</section>
</template>
<style scoped>
@keyframes scroll-dot {
0% {
transform: translateY(0);
opacity: 1;
}
100% {
transform: translateY(15px);
opacity: 0;
}
}
.animate-scroll-dot {
animation: scroll-dot 2s infinite;
}
/* Verhindert Text-Markierung während man mit der Maus über den Canvas fährt */
.select-none {
user-select: none;
}
</style>
@@ -0,0 +1,200 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import axios from 'axios';
const isTwitchLive = ref(false);
const isKickLive = ref(false);
const checkLiveStatus = async () => {
try {
const { data } = await axios.get('/api/live-status');
isTwitchLive.value = data.twitch;
isKickLive.value = data.kick;
} catch (e) {
console.error('Status-Check fehlgeschlagen', e);
}
};
const trackSocial = async (platform: string) => {
try {
await axios.post('/api/track-social', { platform });
} catch (e) {
console.error('Tracking failed', e);
}
};
onMounted(() => {
checkLiveStatus();
// Alle 5 Minuten im Hintergrund aktualisieren
setInterval(checkLiveStatus, 300000);
});
</script>
<template>
<section
id="find-me"
class="social-section relative z-30 bg-gray-950/20 py-32"
>
<div class="container mx-auto px-6">
<h2
class="mb-24 text-center text-5xl font-black tracking-tighter text-white uppercase italic"
>
Join the <span class="text-purple-500">Squad</span>
</h2>
<div
class="mx-auto grid max-w-7xl grid-cols-1 gap-8 md:grid-cols-3"
>
<a
href="https://www.twitch.tv/bratander1ste"
target="_blank"
@click="trackSocial('twitch')"
class="social-card group twitch flex cursor-pointer flex-col items-center gap-6 rounded-[30px] border border-white/10 bg-white/5 p-8 transition-all duration-500 ease-out hover:border-[#9146FF] hover:bg-white/10"
:class="{ 'live-active-twitch': isTwitchLive }"
>
<div v-if="isTwitchLive" class="live-indicator">
<span class="pulse-dot"></span> LIVE
</div>
<i
class="fab fa-twitch text-6xl text-[#9146FF] drop-shadow-[0_0_15px_rgba(145,70,255,0.4)] transition-transform duration-500 group-hover:scale-110"
></i>
<div class="text-center">
<h4
class="mb-2 text-2xl font-black tracking-tight text-white"
>
TWITCH
</h4>
<p class="text-sm font-medium text-gray-400">
Action jeden Abend live
</p>
</div>
</a>
<a
href="#"
@click="trackSocial('instagram')"
class="social-card group instagram flex cursor-pointer flex-col items-center gap-6 rounded-[30px] border border-white/10 bg-white/5 p-8 transition-all duration-500 ease-out hover:border-[#E1306C] hover:bg-white/10"
>
<i
class="fab fa-instagram text-6xl text-pink-500 drop-shadow-[0_0_15px_rgba(236,72,153,0.4)] transition-transform duration-500 group-hover:scale-110"
></i>
<div class="text-center">
<h4
class="mb-2 text-2xl font-black tracking-tight text-white"
>
INSTAGRAM
</h4>
<p class="text-sm font-medium text-gray-400">
News & Giveaways
</p>
</div>
</a>
<a
href="https://kick.com/Bratander1ste"
target="_blank"
@click="trackSocial('kick')"
class="social-card group kick flex cursor-pointer flex-col items-center gap-6 rounded-[30px] border border-white/10 bg-white/5 p-8 transition-all duration-500 ease-out hover:border-[#53FC18] hover:bg-white/10"
:class="{ 'live-active-kick': isKickLive }"
>
<div v-if="isKickLive" class="live-indicator">
<span class="pulse-dot"></span> LIVE
</div>
<i
class="fas fa-bolt text-6xl text-[#53FC18] drop-shadow-[0_0_15px_rgba(83,252,24,0.4)] transition-transform duration-500 group-hover:scale-110"
></i>
<div class="text-center">
<h4
class="mb-2 text-2xl font-black tracking-tight text-[#53FC18]"
>
KICK
</h4>
<p class="text-sm font-medium text-gray-400">
The home of high stakes
</p>
</div>
</a>
</div>
</div>
</section>
</template>
<style scoped>
.social-section {
perspective: 2000px;
}
.social-card {
transform-style: preserve-3d;
position: relative;
}
/* Initiale 3D Lage */
.social-card.twitch {
transform: rotateY(-10deg) rotateX(5deg);
}
.social-card.kick {
transform: rotateY(10deg) rotateX(5deg);
}
.social-card:hover {
transform: rotateY(0deg) rotateX(0deg) translateZ(20px) !important;
}
/* LIVE INDICATOR */
.live-indicator {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 0, 0, 0.15);
border: 1px solid #ff0000;
color: #ff4d4d;
padding: 4px 12px;
border-radius: 99px;
font-size: 11px;
font-weight: 900;
display: flex;
align-items: center;
gap: 6px;
box-shadow: 0 0 15px rgba(255, 0, 0, 0.4);
z-index: 50;
}
.pulse-dot {
width: 8px;
height: 8px;
background: #ff0000;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0% {
transform: scale(0.9);
opacity: 1;
box-shadow: 0 0 0 0 rgba(255, 0, 0, 0.7);
}
70% {
transform: scale(1);
opacity: 0.8;
box-shadow: 0 0 0 10px rgba(255, 0, 0, 0);
}
100% {
transform: scale(0.9);
opacity: 1;
}
}
/* LIVE HIGHLIGHTS */
.live-active-twitch {
border-color: rgba(145, 70, 255, 0.8) !important;
box-shadow: 0 0 40px rgba(145, 70, 255, 0.2);
background: rgba(145, 70, 255, 0.05) !important;
}
.live-active-kick {
border-color: rgba(83, 252, 24, 0.8) !important;
box-shadow: 0 0 40px rgba(83, 252, 24, 0.2);
background: rgba(83, 252, 24, 0.05) !important;
}
</style>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AlertVariants } from "."
import { cn } from "@/lib/utils"
import { alertVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: AlertVariants["variant"]
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Alert } from "./Alert.vue"
export { default as AlertDescription } from "./AlertDescription.vue"
export { default as AlertTitle } from "./AlertTitle.vue"
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>
@@ -0,0 +1,3 @@
export { default as Avatar } from "./Avatar.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { BadgeVariants } from "."
import { reactiveOmit } from "@vueuse/core"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants } from "."
const props = defineProps<PrimitiveProps & {
variant?: BadgeVariants["variant"]
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>
+26
View File
@@ -0,0 +1,26 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
:class="props.class"
>
<slot />
</nav>
</template>
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { MoreHorizontal } from "lucide-vue-next"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
:class="cn('flex size-9 items-center justify-center', props.class)"
>
<slot>
<MoreHorizontal class="size-4" />
</slot>
<span class="sr-only">More</span>
</span>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
data-slot="breadcrumb-item"
:class="cn('inline-flex items-center gap-1.5', props.class)"
>
<slot />
</li>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<PrimitiveProps & { class?: HTMLAttributes["class"] }>(), {
as: "a",
})
</script>
<template>
<Primitive
data-slot="breadcrumb-link"
:as="as"
:as-child="asChild"
:class="cn('hover:text-foreground transition-colors', props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<ol
data-slot="breadcrumb-list"
:class="cn('text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5', props.class)"
>
<slot />
</ol>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
:class="cn('text-foreground font-normal', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from "vue"
import { ChevronRight } from "lucide-vue-next"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
:class="cn('[&>svg]:size-3.5', props.class)"
>
<slot>
<ChevronRight />
</slot>
</li>
</template>
@@ -0,0 +1,7 @@
export { default as Breadcrumb } from "./Breadcrumb.vue"
export { default as BreadcrumbEllipsis } from "./BreadcrumbEllipsis.vue"
export { default as BreadcrumbItem } from "./BreadcrumbItem.vue"
export { default as BreadcrumbLink } from "./BreadcrumbLink.vue"
export { default as BreadcrumbList } from "./BreadcrumbList.vue"
export { default as BreadcrumbPage } from "./BreadcrumbPage.vue"
export { default as BreadcrumbSeparator } from "./BreadcrumbSeparator.vue"
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,38 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
"icon": "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-action"
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-content"
:class="cn('px-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="card-header"
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardFooter } from "./CardFooter.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-slot="slotProps"
data-slot="checkbox"
v-bind="forwarded"
:class="
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class)"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="grid place-content-center text-current transition-none"
>
<slot v-bind="slotProps">
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>
@@ -0,0 +1 @@
export { default as Checkbox } from "./Checkbox.vue"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui"
import { CollapsibleRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot
v-slot="slotProps"
data-slot="collapsible"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</CollapsibleRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleContentProps } from "reka-ui"
import { CollapsibleContent } from "reka-ui"
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
data-slot="collapsible-content"
v-bind="props"
>
<slot />
</CollapsibleContent>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleTriggerProps } from "reka-ui"
import { CollapsibleTrigger } from "reka-ui"
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger
data-slot="collapsible-trigger"
v-bind="props"
>
<slot />
</CollapsibleTrigger>
</template>
@@ -0,0 +1,3 @@
export { default as Collapsible } from "./Collapsible.vue"
export { default as CollapsibleContent } from "./CollapsibleContent.vue"
export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot
v-slot="slotProps"
data-slot="dialog"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DialogRoot>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogCloseProps } from "reka-ui"
import { DialogClose } from "reka-ui"
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose
data-slot="dialog-close"
v-bind="props"
>
<slot />
</DialogClose>
</template>
@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import DialogOverlay from "./DialogOverlay.vue"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogDescription, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
</script>
<template>
<div
data-slot="dialog-footer"
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DialogOverlayProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogOverlay } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
>
<slot />
</DialogOverlay>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { X } from "lucide-vue-next"
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target as HTMLElement;
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault();
}
}"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DialogTitle, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogTriggerProps } from "reka-ui"
import { DialogTrigger } from "reka-ui"
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger
data-slot="dialog-trigger"
v-bind="props"
>
<slot />
</DialogTrigger>
</template>
@@ -0,0 +1,10 @@
export { default as Dialog } from "./Dialog.vue"
export { default as DialogClose } from "./DialogClose.vue"
export { default as DialogContent } from "./DialogContent.vue"
export { default as DialogDescription } from "./DialogDescription.vue"
export { default as DialogFooter } from "./DialogFooter.vue"
export { default as DialogHeader } from "./DialogHeader.vue"
export { default as DialogOverlay } from "./DialogOverlay.vue"
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
export { default as DialogTitle } from "./DialogTitle.vue"
export { default as DialogTrigger } from "./DialogTrigger.vue"
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DropdownMenuRootEmits, DropdownMenuRootProps } from "reka-ui"
import { DropdownMenuRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot
v-slot="slotProps"
data-slot="dropdown-menu"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</DropdownMenuRoot>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Check } from "lucide-vue-next"
import {
DropdownMenuCheckboxItem,
DropdownMenuItemIndicator,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuCheckboxItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuCheckboxItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuCheckboxItem
data-slot="dropdown-menu-checkbox-item"
v-bind="forwarded"
:class=" cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Check class="size-4" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuCheckboxItem>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuContent,
DropdownMenuPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(
defineProps<DropdownMenuContentProps & { class?: HTMLAttributes["class"] }>(),
{
sideOffset: 4,
},
)
const emits = defineEmits<DropdownMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DropdownMenuGroupProps } from "reka-ui"
import { DropdownMenuGroup } from "reka-ui"
const props = defineProps<DropdownMenuGroupProps>()
</script>
<template>
<DropdownMenuGroup
data-slot="dropdown-menu-group"
v-bind="props"
>
<slot />
</DropdownMenuGroup>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { DropdownMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<DropdownMenuItemProps & {
class?: HTMLAttributes["class"]
inset?: boolean
variant?: "default" | "destructive"
}>(), {
variant: "default",
})
const delegatedProps = reactiveOmit(props, "inset", "variant", "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot />
</DropdownMenuItem>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuLabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { DropdownMenuLabel, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuLabelProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { DropdownMenuRadioGroupEmits, DropdownMenuRadioGroupProps } from "reka-ui"
import {
DropdownMenuRadioGroup,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuRadioGroupProps>()
const emits = defineEmits<DropdownMenuRadioGroupEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRadioGroup
data-slot="dropdown-menu-radio-group"
v-bind="forwarded"
>
<slot />
</DropdownMenuRadioGroup>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Circle } from "lucide-vue-next"
import {
DropdownMenuItemIndicator,
DropdownMenuRadioItem,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuRadioItemProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuRadioItemEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuRadioItem
data-slot="dropdown-menu-radio-item"
v-bind="forwarded"
:class="cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class,
)"
>
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuItemIndicator>
<slot name="indicator-icon">
<Circle class="size-2 fill-current" />
</slot>
</DropdownMenuItemIndicator>
</span>
<slot />
</DropdownMenuRadioItem>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { DropdownMenuSeparatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSeparator,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSeparatorProps & {
class?: HTMLAttributes["class"]
}>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
/>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span
data-slot="dropdown-menu-shortcut"
:class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)"
>
<slot />
</span>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { DropdownMenuSubEmits, DropdownMenuSubProps } from "reka-ui"
import {
DropdownMenuSub,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<DropdownMenuSubProps>()
const emits = defineEmits<DropdownMenuSubEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuSub v-slot="slotProps" data-slot="dropdown-menu-sub" v-bind="forwarded">
<slot v-bind="slotProps" />
</DropdownMenuSub>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
DropdownMenuSubContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<DropdownMenuSubContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
>
<slot />
</DropdownMenuSubContent>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { DropdownMenuSubTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronRight } from "lucide-vue-next"
import {
DropdownMenuSubTrigger,
useForwardProps,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuSubTriggerProps & { class?: HTMLAttributes["class"], inset?: boolean }>()
const delegatedProps = reactiveOmit(props, "class", "inset")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DropdownMenuSubTrigger
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
props.class,
)"
>
<slot />
<ChevronRight class="ml-auto size-4" />
</DropdownMenuSubTrigger>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { DropdownMenuTriggerProps } from "reka-ui"
import { DropdownMenuTrigger, useForwardProps } from "reka-ui"
const props = defineProps<DropdownMenuTriggerProps>()
const forwardedProps = useForwardProps(props)
</script>
<template>
<DropdownMenuTrigger
data-slot="dropdown-menu-trigger"
v-bind="forwardedProps"
>
<slot />
</DropdownMenuTrigger>
</template>
@@ -0,0 +1,16 @@
export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue"
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue"
export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue"
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue"
export { default as DropdownMenuSub } from "./DropdownMenuSub.vue"
export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue"
export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue"
export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue"
export { DropdownMenuPortal } from "reka-ui"
@@ -0,0 +1,28 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { OTPInputEmits, OTPInputProps } from "vue-input-otp"
import { reactiveOmit } from "@vueuse/core"
import { useForwardPropsEmits } from "reka-ui"
import { OTPInput } from "vue-input-otp"
import { cn } from "@/lib/utils"
const props = defineProps<OTPInputProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<OTPInputEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<OTPInput
v-slot="slotProps"
v-bind="forwarded"
:container-class="cn('flex items-center gap-2 has-disabled:opacity-50', props.class)"
data-slot="input-otp"
class="disabled:cursor-not-allowed"
>
<slot v-bind="slotProps" />
</OTPInput>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<div
data-slot="input-otp-group"
v-bind="forwarded"
:class="cn('flex items-center', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { MinusIcon } from "lucide-vue-next"
import { useForwardProps } from "reka-ui"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const forwarded = useForwardProps(props)
</script>
<template>
<div
data-slot="input-otp-separator"
role="separator"
v-bind="forwarded"
>
<slot>
<MinusIcon />
</slot>
</div>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { useForwardProps } from "reka-ui"
import { computed } from "vue"
import { useVueOTPContext } from "vue-input-otp"
import { cn } from "@/lib/utils"
const props = defineProps<{ index: number, class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardProps(delegatedProps)
const context = useVueOTPContext()
const slot = computed(() => context?.value.slots[props.index])
</script>
<template>
<div
v-bind="forwarded"
data-slot="input-otp-slot"
:data-active="slot?.isActive"
:class="cn('data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]', props.class)"
>
{{ slot?.char }}
<div v-if="slot?.hasFakeCaret" class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
</div>
</template>
@@ -0,0 +1,4 @@
export { default as InputOTP } from "./InputOTP.vue"
export { default as InputOTPGroup } from "./InputOTPGroup.vue"
export { default as InputOTPSeparator } from "./InputOTPSeparator.vue"
export { default as InputOTPSlot } from "./InputOTPSlot.vue"
@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { useVModel } from "@vueuse/core"
import { cn } from "@/lib/utils"
const props = defineProps<{
defaultValue?: string | number
modelValue?: string | number
class?: HTMLAttributes["class"]
}>()
const emits = defineEmits<{
(e: "update:modelValue", payload: string | number): void
}>()
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)"
>
</template>
@@ -0,0 +1 @@
export { default as Input } from "./Input.vue"
@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { LabelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { Label } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>
@@ -0,0 +1 @@
export { default as Label } from "./Label.vue"
@@ -0,0 +1,35 @@
<script setup lang="ts">
import type { NavigationMenuRootEmits, NavigationMenuRootProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuRoot,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
import NavigationMenuViewport from "./NavigationMenuViewport.vue"
const props = withDefaults(defineProps<NavigationMenuRootProps & {
class?: HTMLAttributes["class"]
viewport?: boolean
}>(), {
viewport: true,
})
const emits = defineEmits<NavigationMenuRootEmits>()
const delegatedProps = reactiveOmit(props, "class", "viewport")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NavigationMenuRoot
v-slot="slotProps"
data-slot="navigation-menu"
:data-viewport="viewport"
v-bind="forwarded"
:class="cn('group/navigation-menu relative flex max-w-max flex-1 items-center justify-center', props.class)"
>
<slot v-bind="slotProps" />
<NavigationMenuViewport v-if="viewport" />
</NavigationMenuRoot>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import type { NavigationMenuContentEmits, NavigationMenuContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
NavigationMenuContent,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<NavigationMenuContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<NavigationMenuContent
data-slot="navigation-menu-content"
v-bind="forwarded"
:class="cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
props.class,
)"
>
<slot />
</NavigationMenuContent>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { NavigationMenuIndicatorProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { NavigationMenuIndicator, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuIndicatorProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<NavigationMenuIndicator
data-slot="navigation-menu-indicator"
v-bind="forwardedProps"
:class="cn('data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden', props.class)"
>
<div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuIndicator>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { NavigationMenuItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { NavigationMenuItem } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<NavigationMenuItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<NavigationMenuItem
data-slot="navigation-menu-item"
v-bind="delegatedProps"
:class="cn('relative', props.class)"
>
<slot />
</NavigationMenuItem>
</template>

Some files were not shown because too many files have changed in this diff Show More