first commit
This commit is contained in:
commit
e687b4437b
418 files changed
+33577
No files matched your search
@@ -0,0 +1,172 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans:
|
||||
Instrument Sans, ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
|
||||
--radius-lg: var(--radius);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentColor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
body,
|
||||
html {
|
||||
--font-sans:
|
||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif,
|
||||
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(0 0% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(0 0% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(0 0% 3.9%);
|
||||
--primary: hsl(0 0% 9%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(0 0% 92.1%);
|
||||
--secondary-foreground: hsl(0 0% 9%);
|
||||
--muted: hsl(0 0% 96.1%);
|
||||
--muted-foreground: hsl(0 0% 45.1%);
|
||||
--accent: hsl(0 0% 96.1%);
|
||||
--accent-foreground: hsl(0 0% 9%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 92.8%);
|
||||
--input: hsl(0 0% 89.8%);
|
||||
--ring: hsl(0 0% 3.9%);
|
||||
--chart-1: hsl(12 76% 61%);
|
||||
--chart-2: hsl(173 58% 39%);
|
||||
--chart-3: hsl(197 37% 24%);
|
||||
--chart-4: hsl(43 74% 66%);
|
||||
--chart-5: hsl(27 87% 67%);
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: hsl(0 0% 98%);
|
||||
--sidebar-foreground: hsl(240 5.3% 26.1%);
|
||||
--sidebar-primary: hsl(0 0% 10%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 98%);
|
||||
--sidebar-accent: hsl(0 0% 94%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 30%);
|
||||
--sidebar-border: hsl(0 0% 91%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0 0% 3.9%);
|
||||
--foreground: hsl(0 0% 98%);
|
||||
--card: hsl(0 0% 3.9%);
|
||||
--card-foreground: hsl(0 0% 98%);
|
||||
--popover: hsl(0 0% 3.9%);
|
||||
--popover-foreground: hsl(0 0% 98%);
|
||||
--primary: hsl(0 0% 98%);
|
||||
--primary-foreground: hsl(0 0% 9%);
|
||||
--secondary: hsl(0 0% 14.9%);
|
||||
--secondary-foreground: hsl(0 0% 98%);
|
||||
--muted: hsl(0 0% 16.08%);
|
||||
--muted-foreground: hsl(0 0% 63.9%);
|
||||
--accent: hsl(0 0% 14.9%);
|
||||
--accent-foreground: hsl(0 0% 98%);
|
||||
--destructive: hsl(0 84% 60%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 14.9%);
|
||||
--input: hsl(0 0% 14.9%);
|
||||
--ring: hsl(0 0% 83.1%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 7%);
|
||||
--sidebar-foreground: hsl(0 0% 95.9%);
|
||||
--sidebar-primary: hsl(360, 100%, 100%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(0 0% 15.9%);
|
||||
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
|
||||
--sidebar-border: hsl(0 0% 15.9%);
|
||||
--sidebar-ring: hsl(217.2 91.2% 59.8%);
|
||||
--sidebar: hsl(240 5.9% 10%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createInertiaApp } from '@inertiajs/svelte';
|
||||
import AppLayout from '@/layouts/AppLayout.svelte';
|
||||
import AuthLayout from '@/layouts/AuthLayout.svelte';
|
||||
import SettingsLayout from '@/layouts/settings/Layout.svelte';
|
||||
import { initializeFlashToast } from '@/lib/flash-toast';
|
||||
import { initializeTheme } from '@/lib/theme.svelte';
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
layout: (name) => {
|
||||
switch (true) {
|
||||
case name === 'Willkommen':
|
||||
return null;
|
||||
case name.startsWith('auth/'):
|
||||
return AuthLayout;
|
||||
case name.startsWith('settings/'):
|
||||
return [AppLayout, SettingsLayout];
|
||||
default:
|
||||
return AppLayout;
|
||||
}
|
||||
},
|
||||
progress: {
|
||||
color: '#4B5563',
|
||||
},
|
||||
});
|
||||
|
||||
// This will set light / dark mode on page load...
|
||||
initializeTheme();
|
||||
|
||||
// This will listen for flash toast data from the server...
|
||||
initializeFlashToast();
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import CircleAlert from 'lucide-svelte/icons/circle-alert';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
|
||||
let {
|
||||
errors = [],
|
||||
title = 'Something went wrong.',
|
||||
}: {
|
||||
errors: string[];
|
||||
title?: string;
|
||||
} = $props();
|
||||
|
||||
const uniqueErrors = $derived([...new Set(errors)]);
|
||||
</script>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<CircleAlert class="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul class="list-inside list-disc text-sm">
|
||||
{#each uniqueErrors as error (error)}
|
||||
<li>{error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SidebarInset } from '@/components/ui/sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AppVariant } from '@/types';
|
||||
|
||||
let {
|
||||
variant = 'sidebar',
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
variant?: AppVariant;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'sidebar'}
|
||||
<SidebarInset class={className}>
|
||||
{@render children?.()}
|
||||
</SidebarInset>
|
||||
{:else}
|
||||
<main
|
||||
class={cn(
|
||||
'mx-auto flex h-full w-full max-w-7xl flex-1 flex-col gap-4 rounded-xl',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
title = '',
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
||||
const fullTitle = $derived(title ? `${title} - ${appName}` : appName);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{fullTitle}</title>
|
||||
{@render children?.()}
|
||||
</svelte:head>
|
||||
@@ -0,0 +1,281 @@
|
||||
<script lang="ts">
|
||||
import { Link, page } from '@inertiajs/svelte';
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import Folder from 'lucide-svelte/icons/folder';
|
||||
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
|
||||
import Menu from 'lucide-svelte/icons/menu';
|
||||
import Search from 'lucide-svelte/icons/search';
|
||||
import AppLogo from '@/components/AppLogo.svelte';
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.svelte';
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.svelte';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import * as NavigationMenu 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.svelte';
|
||||
import { currentUrlState } from '@/lib/currentUrl.svelte';
|
||||
import { getInitials } from '@/lib/initials';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { BreadcrumbItem, NavItem } from '@/types';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import { index as postsIndex } from '@/routes/posts';
|
||||
|
||||
let {
|
||||
breadcrumbs = [],
|
||||
}: {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
} = $props();
|
||||
|
||||
const auth = $derived(page.props.auth);
|
||||
const url = currentUrlState();
|
||||
|
||||
const activeItemStyles =
|
||||
'text-neutral-900 dark:bg-neutral-800 dark:text-neutral-100';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Posts',
|
||||
href: postsIndex(),
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const rightNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/svelte-starter-kit',
|
||||
icon: Folder,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#svelte',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<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 asChild>
|
||||
{#snippet children(props)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="mr-2 h-9 w-9"
|
||||
onclick={props.onclick}
|
||||
aria-expanded={props['aria-expanded']}
|
||||
>
|
||||
<Menu class="h-5 w-5" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" class="w-75 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 pt-6 pb-10"
|
||||
>
|
||||
<nav class="-mx-3 space-y-1">
|
||||
{#each mainNavItems as item (toUrl(item.href))}
|
||||
<Link
|
||||
href={toUrl(item.href)}
|
||||
class="flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium hover:bg-accent {url.whenCurrentUrl(
|
||||
item.href,
|
||||
url.currentUrl,
|
||||
activeItemStyles,
|
||||
'',
|
||||
) ?? ''}"
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="h-5 w-5" />
|
||||
{/if}
|
||||
{item.title}
|
||||
</Link>
|
||||
{/each}
|
||||
</nav>
|
||||
<div class="flex flex-col space-y-4">
|
||||
{#each rightNavItems as item (toUrl(item.href))}
|
||||
<a
|
||||
href={toUrl(item.href)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center space-x-2 text-sm font-medium"
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="h-5 w-5" />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
<Link href={toUrl(dashboard())} class="flex items-center gap-x-2">
|
||||
<AppLogo />
|
||||
</Link>
|
||||
|
||||
<!-- Desktop Menu -->
|
||||
<div class="hidden h-full lg:flex lg:flex-1">
|
||||
<NavigationMenu.Root class="ml-10 flex h-full items-stretch">
|
||||
<NavigationMenu.List
|
||||
class="flex h-full items-stretch space-x-2"
|
||||
>
|
||||
{#each mainNavItems as item (toUrl(item.href))}
|
||||
<NavigationMenu.Item
|
||||
class="relative flex h-full items-center"
|
||||
>
|
||||
<Link
|
||||
class="{NavigationMenu.Trigger} {url.whenCurrentUrl(
|
||||
item.href,
|
||||
url.currentUrl,
|
||||
activeItemStyles,
|
||||
'',
|
||||
) ?? ''} h-9 cursor-pointer px-4"
|
||||
href={toUrl(item.href)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
{item.title}
|
||||
</Link>
|
||||
{#if url.isCurrentUrl(item.href, url.currentUrl)}
|
||||
<div
|
||||
class="absolute bottom-0 left-0 h-0.5 w-full translate-y-px bg-black dark:bg-white"
|
||||
></div>
|
||||
{/if}
|
||||
</NavigationMenu.Item>
|
||||
{/each}
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
</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">
|
||||
{#each rightNavItems as item (toUrl(item.href))}
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href={toUrl(item.href)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground h-9 w-9 group cursor-pointer"
|
||||
>
|
||||
<span class="sr-only"
|
||||
>{item.title}</span
|
||||
>
|
||||
<item.icon
|
||||
class="size-5 opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
{/snippet}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{item.title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{#snippet children(props)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative size-10 w-auto rounded-full p-1 focus-within:ring-2 focus-within:ring-primary"
|
||||
onclick={props.onclick}
|
||||
aria-expanded={props['aria-expanded']}
|
||||
data-state={props['data-state']}
|
||||
>
|
||||
<Avatar
|
||||
class="size-8 overflow-hidden rounded-full"
|
||||
>
|
||||
{#if auth.user?.avatar}
|
||||
<AvatarImage
|
||||
src={auth.user.avatar}
|
||||
alt={auth.user?.name}
|
||||
/>
|
||||
{/if}
|
||||
<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>
|
||||
{/snippet}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-56">
|
||||
{#if auth.user}
|
||||
<UserMenuContent user={auth.user} />
|
||||
{/if}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if breadcrumbs.length > 1}
|
||||
<div 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} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import AppLogoIcon from '@/components/AppLogoIcon.svelte';
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<AppLogoIcon
|
||||
class="w-8 aspect-square rounded-full border-0.5 border-lime-500 dark:border-lime-600"
|
||||
/>
|
||||
<span class="truncate leading-tight font-semibold text-sm text-lime-600"
|
||||
>FC Dotterbühel</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import hubsch from '@/lib/assets/kings/square/hubsch.jpg';
|
||||
let {
|
||||
class: className = '',
|
||||
...rest
|
||||
}: {
|
||||
class?: string;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class={className} {...rest}>
|
||||
<enhanced:img
|
||||
src={hubsch}
|
||||
alt="Kaiser Hubsch"
|
||||
class="aspect-square rounded-full border dark:border border-lime-600 drop-shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { page } from '@inertiajs/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import type { AppVariant } from '@/types';
|
||||
|
||||
let {
|
||||
variant = 'sidebar',
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
variant?: AppVariant;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const isOpen = $derived(page.props.sidebarOpen);
|
||||
</script>
|
||||
|
||||
{#if variant === 'header'}
|
||||
<div class="flex min-h-screen w-full flex-col {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{:else}
|
||||
<SidebarProvider defaultOpen={isOpen}>
|
||||
{@render children?.()}
|
||||
</SidebarProvider>
|
||||
{/if}
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { Link } from '@inertiajs/svelte';
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import FolderGit2 from 'lucide-svelte/icons/folder-git-2';
|
||||
import LayoutGrid from 'lucide-svelte/icons/layout-grid';
|
||||
import type { Snippet } from 'svelte';
|
||||
import AppLogo from '@/components/AppLogo.svelte';
|
||||
import NavFooter from '@/components/NavFooter.svelte';
|
||||
import NavMain from '@/components/NavMain.svelte';
|
||||
import NavUser from '@/components/NavUser.svelte';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { dashboard } from '@/routes';
|
||||
import type { NavItem } from '@/types';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import { index as postsIndex } from '@/routes/posts';
|
||||
|
||||
let {
|
||||
children,
|
||||
}: {
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Dashboard',
|
||||
href: dashboard(),
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{
|
||||
title: 'Posts',
|
||||
href: postsIndex(),
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const footerNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Repository',
|
||||
href: 'https://github.com/laravel/svelte-starter-kit',
|
||||
icon: FolderGit2,
|
||||
},
|
||||
{
|
||||
title: 'Documentation',
|
||||
href: 'https://laravel.com/docs/starter-kits#svelte',
|
||||
icon: BookOpen,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<Sidebar collapsible="icon" variant="floating">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
{#snippet children(props)}
|
||||
<Link
|
||||
{...props}
|
||||
href={toUrl(dashboard())}
|
||||
class={props.class}
|
||||
>
|
||||
<AppLogo />
|
||||
</Link>
|
||||
{/snippet}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<NavMain items={mainNavItems} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavFooter items={footerNavItems} />
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
{@render children?.()}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import Breadcrumbs from '@/components/Breadcrumbs.svelte';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
let {
|
||||
breadcrumbs = [],
|
||||
}: {
|
||||
breadcrumbs?: BreadcrumbItem[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<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" />
|
||||
{#if breadcrumbs && breadcrumbs.length > 0}
|
||||
<Breadcrumbs {breadcrumbs} />
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import Monitor from 'lucide-svelte/icons/monitor';
|
||||
import Moon from 'lucide-svelte/icons/moon';
|
||||
import Sun from 'lucide-svelte/icons/sun';
|
||||
import type { Component, SvelteComponent } from 'svelte';
|
||||
import { themeState } from '@/lib/theme.svelte';
|
||||
import type { Appearance } from '@/types';
|
||||
|
||||
const { appearance, updateAppearance } = themeState();
|
||||
|
||||
type IconComponent =
|
||||
| Component<{ class?: string }>
|
||||
| (new (...args: any[]) => SvelteComponent<{ class?: string }>);
|
||||
|
||||
const tabs: { value: Appearance; Icon: IconComponent; label: string }[] = [
|
||||
{ value: 'light', Icon: Sun, label: 'Light' },
|
||||
{ value: 'dark', Icon: Moon, label: 'Dark' },
|
||||
{ value: 'system', Icon: Monitor, label: 'System' },
|
||||
];
|
||||
|
||||
function handleAppearanceChange(value: Appearance) {
|
||||
updateAppearance(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800"
|
||||
>
|
||||
{#each tabs as { value, Icon, label } (value)}
|
||||
<button
|
||||
onclick={() => handleAppearanceChange(value)}
|
||||
class="flex items-center rounded-md px-3.5 py-1.5 transition-colors {appearance.value ===
|
||||
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'}"
|
||||
>
|
||||
<Icon class="-ml-1 h-4 w-4" />
|
||||
<span class="ml-1.5 text-sm">{label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { Link } from '@inertiajs/svelte';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import type { BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
let {
|
||||
breadcrumbs = [],
|
||||
}: {
|
||||
breadcrumbs: BreadcrumbItemType[];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{#each breadcrumbs as item, index (item.href)}
|
||||
<BreadcrumbItem>
|
||||
{#if index === breadcrumbs.length - 1}
|
||||
<BreadcrumbPage>{item.title}</BreadcrumbPage>
|
||||
{:else}
|
||||
<BreadcrumbLink asChild>
|
||||
{#snippet children(props)}
|
||||
<Link href={item.href} class={props.class}>
|
||||
{item.title}
|
||||
</Link>
|
||||
{/snippet}
|
||||
</BreadcrumbLink>
|
||||
{/if}
|
||||
</BreadcrumbItem>
|
||||
{#if index !== breadcrumbs.length - 1}
|
||||
<BreadcrumbSeparator />
|
||||
{/if}
|
||||
{/each}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { Form } from '@inertiajs/svelte';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import Heading from '@/components/Heading.svelte';
|
||||
import InputError from '@/components/InputError.svelte';
|
||||
import PasswordInput from '@/components/PasswordInput.svelte';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<Button variant="destructive" data-test="delete-user-button"
|
||||
>Delete account</Button
|
||||
>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<Form
|
||||
{...ProfileController.destroy.form()}
|
||||
class="space-y-6"
|
||||
options={{ preserveScroll: true }}
|
||||
>
|
||||
{#snippet children({ errors, processing })}
|
||||
<div 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>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="password" class="sr-only"
|
||||
>Password</Label
|
||||
>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<DialogClose>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
disabled={processing}
|
||||
data-test="confirm-delete-user-button"
|
||||
>
|
||||
Delete account
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{/snippet}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
title,
|
||||
description = '',
|
||||
variant = 'default',
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
variant?: 'default' | 'small';
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<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>
|
||||
{#if description}
|
||||
<p class="text-sm text-muted-foreground">{description}</p>
|
||||
{/if}
|
||||
</header>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
message = '',
|
||||
class: className = '',
|
||||
}: {
|
||||
message?: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div class={className}>
|
||||
<p class="text-sm text-red-600 dark:text-red-500">{message}</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
let {
|
||||
items = [],
|
||||
class: className = '',
|
||||
}: {
|
||||
items: NavItem[];
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<SidebarGroup class={`group-data-[collapsible=icon]:p-0 ${className}`}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{#each items as item (toUrl(item.href))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
class="text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-neutral-100"
|
||||
asChild
|
||||
>
|
||||
{#snippet children(props)}
|
||||
<a
|
||||
{...props}
|
||||
href={toUrl(item.href)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={props.class}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="size-4 shrink-0" />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/each}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { Link } from '@inertiajs/svelte';
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { currentUrlState } from '@/lib/currentUrl.svelte';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import type { NavItem } from '@/types';
|
||||
|
||||
let {
|
||||
items = [],
|
||||
}: {
|
||||
items: NavItem[];
|
||||
} = $props();
|
||||
|
||||
const url = currentUrlState();
|
||||
</script>
|
||||
|
||||
<SidebarGroup class="px-2 py-0">
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{#each items as item (toUrl(item.href))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={url.isCurrentUrl(item.href, url.currentUrl)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
{#snippet children(props)}
|
||||
<Link
|
||||
{...props}
|
||||
href={toUrl(item.href)}
|
||||
class={props.class}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="size-4 shrink-0" />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
{/snippet}
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{/each}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { page } from '@inertiajs/svelte';
|
||||
import ChevronsUpDown from 'lucide-svelte/icons/chevrons-up-down';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import UserInfo from '@/components/UserInfo.svelte';
|
||||
import UserMenuContent from '@/components/UserMenuContent.svelte';
|
||||
|
||||
const user = $derived(page.props.auth.user);
|
||||
const { isMobile, state: sidebarState } = useSidebar();
|
||||
</script>
|
||||
|
||||
{#if user}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu class="w-full">
|
||||
<DropdownMenuTrigger asChild>
|
||||
{#snippet children(props)}
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
data-test="sidebar-menu-button"
|
||||
onclick={props.onclick}
|
||||
aria-expanded={props['aria-expanded']}
|
||||
data-state={props['data-state']}
|
||||
>
|
||||
<UserInfo {user} />
|
||||
<ChevronsUpDown class="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
class="w-full min-w-0 rounded-lg"
|
||||
side={$sidebarState === 'collapsed' && !$isMobile
|
||||
? 'left'
|
||||
: 'top'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<UserMenuContent {user} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{/if}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import Eye from 'lucide-svelte/icons/eye';
|
||||
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let { class: className = '', ...rest } = $props();
|
||||
|
||||
let showPassword = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
class={cn('pr-10', className)}
|
||||
{...rest}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showPassword = !showPassword)}
|
||||
class="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex items-center rounded-r-md px-3 focus-visible:outline-none focus-visible:ring-[3px]"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabindex={-1}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
const patternId = `pattern-${Math.random().toString(36).substring(2, 9)}`;
|
||||
</script>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { LinkComponentBaseProps, Method } from '@inertiajs/core';
|
||||
import { Link } from '@inertiajs/svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
href,
|
||||
tabindex,
|
||||
method,
|
||||
as,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
href: LinkComponentBaseProps['href'];
|
||||
tabindex?: number;
|
||||
method?: Method;
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
children?: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Link
|
||||
{href}
|
||||
{tabindex}
|
||||
{method}
|
||||
{as}
|
||||
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</Link>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { Form } from '@inertiajs/svelte';
|
||||
import Eye from 'lucide-svelte/icons/eye';
|
||||
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||||
import LockKeyhole from 'lucide-svelte/icons/lock-keyhole';
|
||||
import RefreshCw from 'lucide-svelte/icons/refresh-cw';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import AlertError from '@/components/AlertError.svelte';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { twoFactorAuthState } from '@/lib/twoFactorAuth.svelte';
|
||||
import { regenerateRecoveryCodes } from '@/routes/two-factor';
|
||||
|
||||
const twoFactorAuth = twoFactorAuthState();
|
||||
let isRecoveryCodesVisible = $state(false);
|
||||
let recoveryCodeSectionRef = $state<HTMLDivElement | undefined>();
|
||||
|
||||
async function toggleRecoveryCodesVisibility() {
|
||||
if (
|
||||
!isRecoveryCodesVisible &&
|
||||
!twoFactorAuth.state.recoveryCodesList.length
|
||||
) {
|
||||
await twoFactorAuth.fetchRecoveryCodes();
|
||||
}
|
||||
|
||||
isRecoveryCodesVisible = !isRecoveryCodesVisible;
|
||||
|
||||
if (isRecoveryCodesVisible) {
|
||||
await tick();
|
||||
recoveryCodeSectionRef?.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!twoFactorAuth.state.recoveryCodesList.length) {
|
||||
await twoFactorAuth.fetchRecoveryCodes();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<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 onclick={toggleRecoveryCodesVisibility} class="w-fit">
|
||||
{#if isRecoveryCodesVisible}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
{isRecoveryCodesVisible ? 'Hide' : 'View'} recovery codes
|
||||
</Button>
|
||||
|
||||
{#if isRecoveryCodesVisible && twoFactorAuth.state.recoveryCodesList.length}
|
||||
<Form
|
||||
{...regenerateRecoveryCodes.form()}
|
||||
options={{ preserveScroll: true }}
|
||||
onSuccess={() => twoFactorAuth.fetchRecoveryCodes()}
|
||||
>
|
||||
{#snippet children({ processing })}
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
>
|
||||
<RefreshCw class="size-4" /> Regenerate codes
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Form>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class="relative overflow-hidden transition-all duration-300 {isRecoveryCodesVisible
|
||||
? 'h-auto opacity-100'
|
||||
: 'h-0 opacity-0'}"
|
||||
>
|
||||
{#if twoFactorAuth.state.errors.length}
|
||||
<div class="mt-6">
|
||||
<AlertError errors={twoFactorAuth.state.errors} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-3 space-y-3">
|
||||
<div
|
||||
bind:this={recoveryCodeSectionRef}
|
||||
class="grid gap-1 rounded-lg bg-muted p-4 font-mono text-sm"
|
||||
>
|
||||
{#if !twoFactorAuth.state.recoveryCodesList.length}
|
||||
<div class="space-y-2">
|
||||
{#each { length: 8 } as _, n (n)}
|
||||
<div
|
||||
class="h-4 animate-pulse rounded bg-muted-foreground/20"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#each twoFactorAuth.state.recoveryCodesList as code, index (index)}
|
||||
<div>{code}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { Form } from '@inertiajs/svelte';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
import Copy from 'lucide-svelte/icons/copy';
|
||||
import ScanLine from 'lucide-svelte/icons/scan-line';
|
||||
import { tick } from 'svelte';
|
||||
import AlertError from '@/components/AlertError.svelte';
|
||||
import InputError from '@/components/InputError.svelte';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { themeState } from '@/lib/theme.svelte';
|
||||
import { twoFactorAuthState } from '@/lib/twoFactorAuth.svelte';
|
||||
import { confirm } from '@/routes/two-factor';
|
||||
import type { TwoFactorConfigContent } from '@/types';
|
||||
|
||||
let {
|
||||
requiresConfirmation,
|
||||
twoFactorEnabled,
|
||||
isOpen = $bindable(false),
|
||||
}: {
|
||||
requiresConfirmation: boolean;
|
||||
twoFactorEnabled: boolean;
|
||||
isOpen?: boolean;
|
||||
} = $props();
|
||||
|
||||
const { resolvedAppearance } = themeState();
|
||||
const twoFactorAuth = twoFactorAuthState();
|
||||
|
||||
let showVerificationStep = $state(false);
|
||||
let code = $state('');
|
||||
let copied = $state(false);
|
||||
let pinInputContainerRef = $state<HTMLDivElement>();
|
||||
|
||||
const modalConfig: TwoFactorConfigContent = $derived.by(() => {
|
||||
if (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) {
|
||||
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 qrCodeDataUrl = $derived.by(() => {
|
||||
const qrCodeSvg = twoFactorAuth.state.qrCodeSvg;
|
||||
|
||||
if (!qrCodeSvg) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(qrCodeSvg)}`;
|
||||
});
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
async function handleModalNextStep() {
|
||||
if (requiresConfirmation) {
|
||||
showVerificationStep = true;
|
||||
await tick();
|
||||
pinInputContainerRef?.querySelector('input')?.focus();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
twoFactorAuth.clearSetupData();
|
||||
isOpen = false;
|
||||
}
|
||||
|
||||
function resetModalState() {
|
||||
if (twoFactorEnabled) {
|
||||
twoFactorAuth.clearSetupData();
|
||||
}
|
||||
|
||||
showVerificationStep = false;
|
||||
code = '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
resetModalState();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!twoFactorAuth.state.qrCodeSvg) {
|
||||
twoFactorAuth.fetchSetupData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog bind:open={isOpen}>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<div class="flex flex-col 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">
|
||||
{#each { length: 5 } as _, i (i)}
|
||||
<div
|
||||
class="border-r border-border last:border-r-0"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="absolute inset-0 grid grid-rows-5 opacity-50">
|
||||
{#each { length: 5 } as _, i (i)}
|
||||
<div
|
||||
class="border-b border-border last:border-b-0"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
<ScanLine class="relative z-20 size-6 text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-3 space-y-1 text-center">
|
||||
<DialogTitle>{modalConfig.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{modalConfig.description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex w-auto flex-col items-center justify-center space-y-5"
|
||||
>
|
||||
{#if !showVerificationStep}
|
||||
{#if twoFactorAuth.state.errors.length}
|
||||
<AlertError errors={twoFactorAuth.state.errors} />
|
||||
{: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"
|
||||
>
|
||||
{#if !twoFactorAuth.state.qrCodeSvg}
|
||||
<div
|
||||
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>
|
||||
{:else}
|
||||
<div
|
||||
class="relative z-10 overflow-hidden border p-5"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-square size-full items-center justify-center [&>svg]:size-full"
|
||||
style={resolvedAppearance() === 'dark'
|
||||
? 'filter: invert(1) brightness(1.5)'
|
||||
: undefined}
|
||||
>
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="Two-factor authentication QR code"
|
||||
class="size-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button class="w-full" onclick={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"
|
||||
></div>
|
||||
<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"
|
||||
>
|
||||
{#if !twoFactorAuth.state.manualSetupKey}
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-muted p-3"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={twoFactorAuth.state.manualSetupKey}
|
||||
class="h-full w-full bg-background p-3 text-foreground"
|
||||
/>
|
||||
<button
|
||||
onclick={() =>
|
||||
copyToClipboard(
|
||||
twoFactorAuth.state
|
||||
.manualSetupKey || '',
|
||||
)}
|
||||
class="relative block h-auto border-l border-border px-3 hover:bg-muted"
|
||||
>
|
||||
{#if copied}
|
||||
<Check class="w-4 text-green-500" />
|
||||
{:else}
|
||||
<Copy class="w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Form
|
||||
{...confirm.form()}
|
||||
resetOnError
|
||||
onFinish={() => (code = '')}
|
||||
onSuccess={() => (isOpen = false)}
|
||||
>
|
||||
{#snippet children({ errors: formErrors, processing })}
|
||||
<input type="hidden" name="code" value={code} />
|
||||
<div
|
||||
bind:this={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"
|
||||
bind:value={code}
|
||||
maxlength={6}
|
||||
disabled={processing}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{#each { length: 6 } as _, i (i)}
|
||||
<InputOTPSlot index={i} />
|
||||
{/each}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<InputError
|
||||
message={formErrors?.[
|
||||
'confirmTwoFactorAuthentication.code'
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center space-x-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-auto flex-1"
|
||||
onclick={() =>
|
||||
(showVerificationStep = false)}
|
||||
disabled={processing}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
class="w-auto flex-1"
|
||||
disabled={processing || code.length < 6}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Form>
|
||||
{/if}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { getInitials } from '@/lib/initials';
|
||||
import type { User } from '@/types';
|
||||
|
||||
let {
|
||||
user,
|
||||
showEmail = false,
|
||||
}: {
|
||||
user: User;
|
||||
showEmail?: boolean;
|
||||
} = $props();
|
||||
|
||||
const showAvatar = $derived(user.avatar && user.avatar !== '');
|
||||
</script>
|
||||
|
||||
<Avatar class="h-8 w-8 overflow-hidden rounded-lg">
|
||||
{#if showAvatar}
|
||||
<AvatarImage src={user.avatar!} alt={user.name} />
|
||||
{/if}
|
||||
<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>
|
||||
{#if showEmail}
|
||||
<span class="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { Link, router } from '@inertiajs/svelte';
|
||||
import LogOut from 'lucide-svelte/icons/log-out';
|
||||
import Settings from 'lucide-svelte/icons/settings';
|
||||
import {
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.svelte';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/profile';
|
||||
import type { User } from '@/types';
|
||||
|
||||
let {
|
||||
user,
|
||||
}: {
|
||||
user: User;
|
||||
} = $props();
|
||||
|
||||
function handleLogout(propsOnClick?: () => void) {
|
||||
return () => {
|
||||
propsOnClick?.();
|
||||
router.flushAll();
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenuLabel class="p-0 font-normal">
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<UserInfo {user} showEmail={true} />
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
{#snippet children(props)}
|
||||
<Link
|
||||
class={props.class}
|
||||
href={toUrl(edit())}
|
||||
prefetch
|
||||
onclick={props.onClick}
|
||||
>
|
||||
<Settings class="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Link>
|
||||
{/snippet}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
{#snippet children(props)}
|
||||
<Link
|
||||
class={props.class}
|
||||
href={logout()}
|
||||
as="button"
|
||||
onclick={handleLogout(props.onClick)}
|
||||
data-test="logout-button"
|
||||
>
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Link>
|
||||
{/snippet}
|
||||
</DropdownMenuItem>
|
||||
@@ -0,0 +1,197 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { AlertCircle, Building2, Phone, Mail, Clock } from 'lucide-svelte';
|
||||
import * as Select from '@/components/ui/select/index.js';
|
||||
|
||||
interface ContactFormProps {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
let contactForm: ContactFormProps = {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
subject: 'Web Development',
|
||||
message: '',
|
||||
};
|
||||
|
||||
let invalidInputForm = false;
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const { firstName, lastName, email, subject, message } = contactForm;
|
||||
console.log(contactForm);
|
||||
|
||||
const mailToLink = `mailto:leomirandadev@gmail.com?subject=${subject}&body=Hello I am ${firstName} ${lastName}, my Email is ${email}. %0D%0A${message}`;
|
||||
window.location.href = mailToLink;
|
||||
}
|
||||
|
||||
const subjects = [
|
||||
{ value: 'Web Development', label: 'Web Development' },
|
||||
{ value: 'Mobile Development', label: 'Mobile Development' },
|
||||
{ value: 'Figma Design', label: 'Figma Design' },
|
||||
{ value: 'REST API', label: 'REST API' },
|
||||
{ value: 'FullStack Project', label: 'FullStack Project' },
|
||||
];
|
||||
|
||||
$: triggerContent =
|
||||
subjects.find((s) => s.value === contactForm.subject)?.label ??
|
||||
'Select a subject';
|
||||
</script>
|
||||
|
||||
<section id="contact" class="container py-24 sm:py-32">
|
||||
<section class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg text-primary mb-2 tracking-wider">
|
||||
Contact
|
||||
</h2>
|
||||
<h2 class="text-3xl md:text-4xl font-bold">Connect With Us</h2>
|
||||
</div>
|
||||
<p class="mb-8 text-muted-foreground lg:w-5/6">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit.
|
||||
Voluptatum ipsam sint enim exercitationem ex autem corrupti quas
|
||||
tenetur
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<div class="flex gap-2 mb-1">
|
||||
<Building2 />
|
||||
<div class="font-bold">Find Us</div>
|
||||
</div>
|
||||
<div>742 Evergreen Terrace, Springfield, IL 62704</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex gap-2 mb-1">
|
||||
<Phone />
|
||||
<div class="font-bold">Call Us</div>
|
||||
</div>
|
||||
<div>+1 (619) 123-4567</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex gap-2 mb-1">
|
||||
<Mail />
|
||||
<div class="font-bold">Mail Us</div>
|
||||
</div>
|
||||
<div>leomirandadev@gmail.com</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex gap-2 mb-1">
|
||||
<Clock />
|
||||
<div class="font-bold">Visit Us</div>
|
||||
</div>
|
||||
<div>
|
||||
<div>Monday - Friday</div>
|
||||
<div>8AM - 4PM</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<Card class="bg-muted/60 dark:bg-card">
|
||||
<CardHeader class="text-primary text-2xl" />
|
||||
<CardContent>
|
||||
<form on:submit={handleSubmit} class="grid gap-4">
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<Label for="firstName">First Name</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
placeholder="Leopoldo"
|
||||
value={contactForm.firstName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col w-full gap-1.5">
|
||||
<Label for="lastName">Last Name</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
placeholder="Miranda"
|
||||
value={contactForm.lastName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="contactEmail">Email</Label>
|
||||
<Input
|
||||
id="contactEmail"
|
||||
type="email"
|
||||
placeholder="leomirandadev@gmail.com"
|
||||
value={contactForm.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="contactSubject">Subject</Label>
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={contactForm.subject}
|
||||
>
|
||||
<Select.Trigger id="contactSubject" class="w-full">
|
||||
{triggerContent}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
{#each subjects as subject}
|
||||
<Select.Item
|
||||
value={subject.value}
|
||||
label={subject.label}
|
||||
>
|
||||
{subject.label}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="contactMessage">Message</Label>
|
||||
<Textarea
|
||||
id="contactMessage"
|
||||
placeholder="Your message..."
|
||||
rows={5}
|
||||
bind:value={contactForm.message}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if invalidInputForm}
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>
|
||||
There is an error in the form. Please check your
|
||||
input.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
<Button class="mt-4">Send message</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter />
|
||||
</Card>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
interface FAQProps {
|
||||
question: string;
|
||||
answer: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
|
||||
const FAQList: FAQProps[] = [
|
||||
{
|
||||
question: 'Is this template free?',
|
||||
answer: 'Yes. It is a free Shadcn/Svelte template.',
|
||||
value: 'item-1',
|
||||
},
|
||||
{
|
||||
question:
|
||||
'Duis aute irure dolor in reprehenderit in voluptate velit?',
|
||||
answer: 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Sint labore quidem quam consectetur sapiente, iste rerum reiciendis animi nihil nostrum sit quo, modi quod.',
|
||||
value: 'item-2',
|
||||
},
|
||||
{
|
||||
question:
|
||||
'Lorem ipsum dolor sit amet Consectetur natus dolor minus quibusdam?',
|
||||
answer: 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Labore qui nostrum reiciendis veritatis.',
|
||||
value: 'item-3',
|
||||
},
|
||||
{
|
||||
question: 'Excepteur sint occaecat cupidata non proident sunt?',
|
||||
answer: 'Lorem ipsum dolor sit amet consectetur, adipisicing elit.',
|
||||
value: 'item-4',
|
||||
},
|
||||
{
|
||||
question:
|
||||
'Enim ad minim veniam, quis nostrud exercitation ullamco laboris?',
|
||||
answer: 'consectetur adipisicing elit. Sint labore.',
|
||||
value: 'item-5',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<section id="faq" class="container md:w-175 py-24 sm:py-32">
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-lg text-primary text-center mb-2 tracking-wider">
|
||||
FAQS
|
||||
</h2>
|
||||
<h2 class="text-3xl md:text-4xl text-center font-bold">
|
||||
Common Questions
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" class="w-full">
|
||||
{#each FAQList as { question, answer, value }}
|
||||
<AccordionItem {value}>
|
||||
<AccordionTrigger class="text-left">
|
||||
{question}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
{answer}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
{/each}
|
||||
</Accordion>
|
||||
|
||||
<h3 class="font-medium mt-4">
|
||||
Still have questions?
|
||||
<a href="#contact" class="text-muted-foreground">
|
||||
<span class="underline">Contact us</span>
|
||||
</a>
|
||||
</h3>
|
||||
</section>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Paintbrush,
|
||||
MessageCircle,
|
||||
TabletSmartphone,
|
||||
BadgeCheck,
|
||||
Goal,
|
||||
PictureInPicture,
|
||||
MousePointerClick,
|
||||
Newspaper,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface FeaturesProps {
|
||||
icon:
|
||||
| 'tabletSmartphone'
|
||||
| 'badgeCheck'
|
||||
| 'goal'
|
||||
| 'pictureInPicture'
|
||||
| 'paintbrush'
|
||||
| 'mousePointerClick'
|
||||
| 'messageCircle'
|
||||
| 'newspaper';
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const featureList: FeaturesProps[] = [
|
||||
{
|
||||
icon: 'tabletSmartphone',
|
||||
title: 'Handy Freundlich',
|
||||
description:
|
||||
'Wir sind eine Website die auf Smartphones und Tablets gut lesbar, bedienbar und korrekt dargestellt wird, ohne dass Nutzer zoomen oder seitlich scrollen müssen.',
|
||||
},
|
||||
{
|
||||
icon: 'badgeCheck',
|
||||
title: 'Social Proof',
|
||||
description:
|
||||
'Bei ist die Möglicheit sich am Verhalten anderer zu orientieren unwahrscheinllich, du bist alleine und must lernen deine Entscheidungen selbstständig zu treffen.',
|
||||
},
|
||||
{
|
||||
icon: 'goal',
|
||||
title: 'Targeted Content',
|
||||
description:
|
||||
'Lorem ipsum dolor sit amet consectetur adipisicing elit. odio ea accusamus aperiam.',
|
||||
},
|
||||
{
|
||||
icon: 'pictureInPicture',
|
||||
title: 'Strong Visuals',
|
||||
description:
|
||||
'Lorem elit. A odio velit cum aliquam. Natus consectetur dolores, odio ea accusamus aperiam.',
|
||||
},
|
||||
{
|
||||
icon: 'mousePointerClick',
|
||||
title: 'Clear CTA',
|
||||
description:
|
||||
'Lorem ipsum dolor sit amet consectetur adipisicing. odio ea accusamus consectetur.',
|
||||
},
|
||||
{
|
||||
icon: 'newspaper',
|
||||
title: 'Clear Headline',
|
||||
description:
|
||||
'Lorem ipsum dolor sit amet consectetur adipisicing elit. A odio velit cum aliquam. Natus consectetur.',
|
||||
},
|
||||
];
|
||||
|
||||
const iconMap = {
|
||||
tabletSmartphone: TabletSmartphone,
|
||||
badgeCheck: BadgeCheck,
|
||||
goal: Goal,
|
||||
pictureInPicture: PictureInPicture,
|
||||
paintbrush: Paintbrush,
|
||||
mousePointerClick: MousePointerClick,
|
||||
messageCircle: MessageCircle,
|
||||
newspaper: Newspaper,
|
||||
};
|
||||
</script>
|
||||
|
||||
<section id="features" class="container py-24 sm:py-32">
|
||||
<h2 class="text-lg text-primary text-center mb-2 tracking-wider">
|
||||
Features
|
||||
</h2>
|
||||
|
||||
<h2 class="text-3xl md:text-4xl text-center font-bold mb-4">
|
||||
Was macht uns so besonderes
|
||||
</h2>
|
||||
|
||||
<h3 class="md:w-1/2 mx-auto text-xl text-center text-muted-foreground mb-8">
|
||||
Der Überlieferung nach ist das Spiel in der heutigen Form in der Zeit
|
||||
der napoleonischen Kriege in den Gefangenenlagern um Mantua entstanden.
|
||||
Damals verbrachten die miteinander verbündeten Franzosen und Italienern
|
||||
ihre Freizeit mit den in den dortigen Feldlagern festgehalenen Tirolern.
|
||||
Der Name entstand aus dem französischen Begriff va tout (letzter
|
||||
Trumpf).
|
||||
</h3>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each featureList as { icon, title, description }}
|
||||
<Card class="h-full bg-background border-0 shadow-none">
|
||||
<CardHeader class="flex justify-center items-center">
|
||||
<div
|
||||
class="bg-primary/20 p-2 rounded-full ring-8 ring-primary/10 mb-4"
|
||||
>
|
||||
<svelte:component
|
||||
this={iconMap[icon]}
|
||||
class="size-6 text-primary"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent class="text-muted-foreground text-center">
|
||||
{description}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import hubsch from '@/lib/assets/kings/square/hubsch.jpg';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import WebWerkstatt from '@/components/my/WebWerkstatt.svelte';
|
||||
</script>
|
||||
|
||||
<footer id="footer" class="container py-24 pb-16 sm:py-32 sm:pb-24">
|
||||
<div class="p-10 bg-muted/50 dark:bg-card border rounded-2xl">
|
||||
<div
|
||||
class="grid grid-cols-2 md:grid-cols-4 xl:grid-cols-6 gap-x-12 gap-y-8"
|
||||
>
|
||||
<div class="col-span-full xl:col-span-2">
|
||||
<a href="/" class="flex font-bold items-center gap-2">
|
||||
<enhanced:img
|
||||
src={hubsch}
|
||||
alt="Kaiser Hubsch"
|
||||
class="max-w-8 aspect-square rounded-full border-3 dark:border border-lime-600 drop-shadow-2xl"
|
||||
/>
|
||||
<h3 class="text-2xl">FC Dotterbühel</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="font-bold text-lg">Kontakt</h3>
|
||||
<div>
|
||||
<a
|
||||
href="https://github.com"
|
||||
class="opacity-60 hover:opacity-100">Github</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://twitter.com"
|
||||
class="opacity-60 hover:opacity-100">Twitter</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://instagram.com"
|
||||
class="opacity-60 hover:opacity-100">Instagram</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="font-bold text-lg">Platformen</h3>
|
||||
<div>
|
||||
<span class="opacity-60 hover:opacity-100 cursor-default"
|
||||
>iOS</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60 hover:opacity-100 cursor-default"
|
||||
>Android</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="opacity-60 hover:opacity-100 cursor-default"
|
||||
>Web</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="font-bold text-lg">Hilfe</h3>
|
||||
<div>
|
||||
<a href="#contact" class="opacity-60 hover:opacity-100"
|
||||
>Contact Us</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#faq" class="opacity-60 hover:opacity-100">FAQ</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#contact" class="opacity-60 hover:opacity-100"
|
||||
>Feedback</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="font-bold text-lg">Soziale Medien</h3>
|
||||
<div>
|
||||
<a
|
||||
href="https://twitch.tv"
|
||||
class="opacity-60 hover:opacity-100">Twitch</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://discord.com"
|
||||
class="opacity-60 hover:opacity-100">Discord</a
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://dribbble.com"
|
||||
class="opacity-60 hover:opacity-100">Dribbble</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator class="my-6" />
|
||||
|
||||
<WebWerkstatt />
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { Link, page } from '@inertiajs/svelte';
|
||||
import { toUrl } from '@/lib/utils';
|
||||
import { dashboard, login, register } from '@/routes';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import burg from '@/lib/assets/img/burg.jpg';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import { toggleMode } from 'mode-watcher';
|
||||
|
||||
let {
|
||||
canRegister = true,
|
||||
}: {
|
||||
canRegister: boolean;
|
||||
} = $props();
|
||||
|
||||
const auth = $derived(page.props.auth);
|
||||
</script>
|
||||
|
||||
<div class="text-center space-y-8">
|
||||
<Badge variant="outline" class="text-sm py-2">
|
||||
<span class="mr-2 text-primary">
|
||||
<Badge>Neu</Badge>
|
||||
</span>
|
||||
<span>Für Android, Apple oder Desktop!</span>
|
||||
</Badge>
|
||||
|
||||
<div class="max-w-3xl mx-auto text-center text-5xl md:text-6xl font-bold">
|
||||
<h1>
|
||||
<span
|
||||
class="text-transparent bg-linear-to-r from-lime-600 to-primary bg-clip-text"
|
||||
>
|
||||
Ladinisch Watten
|
||||
</span>
|
||||
<br />FC Dotterbühel
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p class="max-w-screen-sm mx-auto text-xl text-muted-foreground">
|
||||
Ladinisch Watten vom Handy, Tablet oder PC, mit deinen Freunden oder auf
|
||||
der offenen Platform, nach deinen Regeln, mit begleitenden Statistiken.
|
||||
</p>
|
||||
<nav class="flex items-center justify-center gap-4">
|
||||
{#if auth.user}
|
||||
<Link href={toUrl(dashboard())} as="button">Dashboard</Link>
|
||||
{:else}
|
||||
<Link href={toUrl(login())}>
|
||||
<Button>Anmelden</Button>
|
||||
</Link>
|
||||
{#if canRegister}
|
||||
<Link href={toUrl(register())}>
|
||||
<Button variant="outline">Registrieren</Button>
|
||||
</Link>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button onclick={toggleMode} variant="outline" size="icon">
|
||||
<SunIcon
|
||||
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all! dark:scale-0 dark:-rotate-90"
|
||||
/>
|
||||
<MoonIcon
|
||||
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all! dark:scale-100 dark:rotate-0"
|
||||
/>
|
||||
<span class="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="relative group mt-14">
|
||||
<div
|
||||
class="absolute -top-6 right-12 w-[90%] h-12 lg:h-[80%] bg-primary/50 blur-3xl rounded-full img-shadow-animation"
|
||||
></div>
|
||||
|
||||
<enhanced:img
|
||||
class="w-full mx-auto rounded-xl"
|
||||
src={burg}
|
||||
alt="dashboard using shadcn-svelte"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="absolute bottom-0 left-0 w-full h-20 md:h-28 bg-linear-to-b from-background/0 via-background/50 to-background"
|
||||
></div>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import hubsch from '@/lib/assets/kings/square/hubsch.jpg';
|
||||
</script>
|
||||
|
||||
<enhanced:img
|
||||
src={hubsch}
|
||||
alt="Kaiser Hubsch"
|
||||
class="max-w-40 md:max-w-50 aspect-square rounded-full border-3 dark:border border-lime-600 drop-shadow-2xl"
|
||||
/>
|
||||
@@ -0,0 +1,219 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Check } from 'lucide-svelte';
|
||||
|
||||
interface PlanProps {
|
||||
title: string;
|
||||
popular: boolean;
|
||||
price: number;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
benefitList: string[];
|
||||
}
|
||||
|
||||
const plans: PlanProps[] = [
|
||||
{
|
||||
title: 'Free',
|
||||
popular: false,
|
||||
price: 0,
|
||||
description:
|
||||
'Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.',
|
||||
buttonText: 'Start Free Trial',
|
||||
benefitList: [
|
||||
'1 team member',
|
||||
'1 GB storage',
|
||||
'Upto 2 pages',
|
||||
'Community support',
|
||||
'AI assistance',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Premium',
|
||||
popular: true,
|
||||
price: 45,
|
||||
description:
|
||||
'Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.',
|
||||
buttonText: 'Get Started',
|
||||
benefitList: [
|
||||
'4 team member',
|
||||
'8 GB storage',
|
||||
'Upto 6 pages',
|
||||
'Priority support',
|
||||
'AI assistance',
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Enterprise',
|
||||
popular: false,
|
||||
price: 120,
|
||||
description:
|
||||
'Lorem ipsum dolor sit, amet ipsum consectetur adipisicing elit.',
|
||||
buttonText: 'Contact Us',
|
||||
benefitList: [
|
||||
'10 team member',
|
||||
'20 GB storage',
|
||||
'Upto 10 pages',
|
||||
'Phone & email support',
|
||||
'AI assistance',
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="container py-16 sm:py-32">
|
||||
<h2 class="text-lg text-primary text-center mb-2 tracking-wider">
|
||||
Pricing
|
||||
</h2>
|
||||
|
||||
<h2 class="text-3xl md:text-4xl text-center font-bold mb-4">
|
||||
Get unlimited access
|
||||
</h2>
|
||||
|
||||
<h3
|
||||
class="md:w-1/2 mx-auto text-xl text-center text-muted-foreground pb-14"
|
||||
>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing reiciendis.
|
||||
</h3>
|
||||
|
||||
<!-- Mobile First Layout -->
|
||||
<div class="block lg:hidden space-y-6">
|
||||
{#each plans as { title, popular, price, description, buttonText, benefitList }}
|
||||
<div class="relative">
|
||||
<Card class={popular ? 'popular-mobile' : ''}>
|
||||
{#if popular}
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<span
|
||||
class="bg-primary text-primary-foreground text-xs sm:text-sm font-medium px-3 sm:px-4 py-1 rounded-full whitespace-nowrap"
|
||||
>
|
||||
Most Popular
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<CardHeader>
|
||||
<CardTitle class="pb-2 text-lg sm:text-xl">
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription class="pb-4 text-sm sm:text-base"
|
||||
>{description}</CardDescription
|
||||
>
|
||||
|
||||
<div>
|
||||
<span class="text-2xl sm:text-3xl font-bold"
|
||||
>${price}</span
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground text-sm sm:text-base"
|
||||
>
|
||||
/month</span
|
||||
>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div class="space-y-3 sm:space-y-4">
|
||||
{#each benefitList as benefit}
|
||||
<span
|
||||
class="flex items-center text-sm sm:text-base"
|
||||
>
|
||||
<Check
|
||||
class="text-primary mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
<span>{benefit}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant={popular ? 'default' : 'secondary'}
|
||||
class="w-full"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop Layout -->
|
||||
<div class="hidden lg:grid lg:grid-cols-3 gap-8 relative">
|
||||
{#each plans as { title, popular, price, description, buttonText, benefitList }}
|
||||
<div
|
||||
class="relative {popular
|
||||
? 'scale-110 hover:scale-112 z-10'
|
||||
: 'hover:scale-105'} transition-all duration-200"
|
||||
>
|
||||
<Card
|
||||
class={popular
|
||||
? 'popular-desktop'
|
||||
: 'hover:border-primary/50'}
|
||||
>
|
||||
{#if popular}
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2">
|
||||
<span
|
||||
class="bg-primary text-primary-foreground text-sm font-medium px-4 py-1 rounded-full whitespace-nowrap shadow-lg"
|
||||
>
|
||||
Most Popular
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<CardHeader>
|
||||
<CardTitle class="pb-2 text-lg sm:text-xl">
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription class="pb-4 text-sm sm:text-base"
|
||||
>{description}</CardDescription
|
||||
>
|
||||
|
||||
<div>
|
||||
<span class="text-2xl sm:text-3xl font-bold"
|
||||
>${price}</span
|
||||
>
|
||||
<span
|
||||
class="text-muted-foreground text-sm sm:text-base"
|
||||
>
|
||||
/month</span
|
||||
>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div class="space-y-3 sm:space-y-4">
|
||||
{#each benefitList as benefit}
|
||||
<span
|
||||
class="flex items-center text-sm sm:text-base"
|
||||
>
|
||||
<Check
|
||||
class="text-primary mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
||||
/>
|
||||
<span>{benefit}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant={popular ? 'default' : 'secondary'}
|
||||
class="w-full"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import andi from '@/lib/assets/athleten/andi.png';
|
||||
import edgar from '@/lib/assets/athleten/edgar.png';
|
||||
import salam from '@/lib/assets/athleten/salam.png';
|
||||
import helli from '@/lib/assets/athleten/helli.png';
|
||||
import hechi from '@/lib/assets/athleten/hechi.png';
|
||||
import clemens from '@/lib/assets/athleten/clemens.png';
|
||||
import gerhard from '@/lib/assets/athleten/gerhard.png';
|
||||
import hubsch from '@/lib/assets/athleten/hubsch.png';
|
||||
import rudi from '@/lib/assets/athleten/rudi.png';
|
||||
import rene from '@/lib/assets/athleten/rene.png';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardFooter,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
interface TeamProps {
|
||||
imageUrl: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
positions: string[];
|
||||
}
|
||||
|
||||
const teamList: TeamProps[] = [
|
||||
{
|
||||
imageUrl: andi,
|
||||
firstName: 'Andi',
|
||||
lastName: 'Tengler',
|
||||
positions: ['Präsident', 'Optimist', 'Wirt'],
|
||||
},
|
||||
{
|
||||
imageUrl: edgar,
|
||||
firstName: 'Prof.',
|
||||
lastName: 'Waffler',
|
||||
positions: ['Kassier', 'Wein Koordinator', 'Venezianer'],
|
||||
},
|
||||
{
|
||||
imageUrl: salam,
|
||||
firstName: 'Salam',
|
||||
lastName: '',
|
||||
positions: ['Pizzaiolo', 'Wattergott Theist', '2x König'],
|
||||
},
|
||||
{
|
||||
imageUrl: helli,
|
||||
firstName: 'Helli',
|
||||
lastName: 'Cool',
|
||||
positions: ['Aufsteiger', 'Musiker', 'Blues Knacker Fan'],
|
||||
},
|
||||
{
|
||||
imageUrl: gerhard,
|
||||
firstName: 'Gerhard',
|
||||
lastName: '',
|
||||
positions: ['Risikomanagment', '8x König'],
|
||||
},
|
||||
{
|
||||
imageUrl: hechi,
|
||||
firstName: 'Hechi',
|
||||
lastName: 'THahn',
|
||||
positions: ['Schriftführer', 'Recordhalter', 'Musiker', '2x König'],
|
||||
},
|
||||
{
|
||||
imageUrl: clemens,
|
||||
firstName: 'Celemens',
|
||||
lastName: '',
|
||||
positions: ['Vorstand Regeln', '2x König'],
|
||||
},
|
||||
{
|
||||
imageUrl: hubsch,
|
||||
firstName: 'Hubsch',
|
||||
lastName: '',
|
||||
positions: ['Risikospieler', 'Wattergott Agnostiker', '3x König'],
|
||||
},
|
||||
{
|
||||
imageUrl: rudi,
|
||||
firstName: 'Rudi',
|
||||
lastName: '',
|
||||
positions: ['Gastspieler', 'Gerngesehen'],
|
||||
},
|
||||
{
|
||||
imageUrl: rene,
|
||||
firstName: 'René',
|
||||
lastName: '',
|
||||
positions: [
|
||||
'Quizmaster',
|
||||
'Wattergott Atheist',
|
||||
'Pessimist',
|
||||
'3x König',
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<section
|
||||
id="team"
|
||||
class="container max-w-7xl mx-auto px-4 py-16 sm:py-24 lg:py-32"
|
||||
>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-lg text-primary text-center mb-3 tracking-wider">
|
||||
Athleten
|
||||
</h2>
|
||||
|
||||
<h2 class="text-3xl md:text-4xl text-center font-bold">
|
||||
FC Dotterbühel Dream Team
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-6 md:gap-8 justify-center items-start"
|
||||
>
|
||||
{#each teamList as { imageUrl, firstName, lastName, positions }}
|
||||
<Card
|
||||
class="bg-muted/60 dark:bg-card flex flex-col h-full overflow-hidden group/hoverimg p-0 "
|
||||
>
|
||||
<CardHeader class="p-0 gap-0 ">
|
||||
<div class="h-full overflow-hidden">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`${firstName} ${lastName}`}
|
||||
class="w-full bg-linear-to-bl from-lime-300 to-lime-950 aspect-square object-cover saturate-0 transition-all duration-300 ease-in-out group-hover/hoverimg:saturate-100 group-hover/hoverimg:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<CardTitle class="p-4 sm:p-6">
|
||||
{firstName}
|
||||
<span class="text-primary">{lastName}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<div class="px-4 sm:px-6 grow">
|
||||
{#each positions as position, index}
|
||||
<CardContent class="p-0 pb-2 text-muted-foreground">
|
||||
{position}{#if index < positions.length - 1},{/if}
|
||||
</CardContent>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<CardFooter class="space-x-4 px-4 sm:px-6 py-4"
|
||||
>Fc Dotterbühel</CardFooter
|
||||
>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
<section class="mb-5">
|
||||
<a
|
||||
href="https://webwerkstatt.tirol"
|
||||
target="_blank"
|
||||
class="flex gap-2 w-72 mx-auto cursor-pointer text-xs bg-white dark:bg-black justify-center rounded-sm opacity-90 border border-gray-300 dark:border-gray-600"
|
||||
>
|
||||
<span>© 2026</span>
|
||||
<span class=" text-slate-500 dark:text-slate-400">made by</span>
|
||||
<span class=" tracking-tight text-xs font-bold">
|
||||
Web<span
|
||||
class="text-primary-800 font-black text-lime-600 dark:text-lime-500"
|
||||
>Werkstatt</span
|
||||
><span class="text-black dark:text-slate-400 font-light"
|
||||
>.tirol</span
|
||||
>
|
||||
</span>
|
||||
<div class="w-4 text-green-900 dark:text-lime-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M164 78a26 26 0 1 0-26-26a26 26 0 0 0 26 26m0-40a14 14 0 1 1-14 14a14 14 0 0 1 14-14m89.16 158.94l-54.56-92.08a13.9 13.9 0 0 0-12-6.86a13.88 13.88 0 0 0-12 6.86l-27.88 47.05l-46.56-79a14 14 0 0 0-24.13 0L2.83 197A6 6 0 0 0 8 206h240a6 6 0 0 0 5.16-9.06M86.27 79a2 2 0 0 1 3.46 0l25.34 43H60.93ZM18.5 194l35.36-60h68.29l19.3 32.77l16 27.2Zm152.93 0l-17.85-30.29L184.83 111a2 2 0 0 1 1.72-1a1.93 1.93 0 0 1 1.72 1l49.2 83Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>v 1.0</span>
|
||||
</a>
|
||||
</section>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "@/lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="accordion-content"
|
||||
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"pt-0 pb-2.5 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "@/lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="accordion-item"
|
||||
class={cn("not-last:border-b", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "@/lib/utils.js";
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||
level?: AccordionPrimitive.HeaderProps["level"];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
bind:ref
|
||||
class={cn(
|
||||
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-lg py-2.5 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "@/lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="accordion"
|
||||
class={cn("cn-accordion flex w-full flex-col", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Root from "./accordion.svelte";
|
||||
import Content from "./accordion-content.svelte";
|
||||
import Item from "./accordion-item.svelte";
|
||||
import Trigger from "./accordion-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Trigger,
|
||||
//
|
||||
Root as Accordion,
|
||||
Content as AccordionContent,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Variant = 'default' | 'destructive';
|
||||
|
||||
const base =
|
||||
'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';
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
};
|
||||
|
||||
let {
|
||||
variant = 'default',
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
variant?: Variant;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="alert" class={cn(base, variantClasses[variant], className)} role="alert">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
class={cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="alert-title" class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Alert } from './Alert.svelte';
|
||||
export { default as AlertDescription } from './AlertDescription.svelte';
|
||||
export { default as AlertTitle } from './AlertTitle.svelte';
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let { class: className = '', children, ...rest }: { class?: string; children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let { class: className = '', children, ...rest }: { class?: string; children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn(
|
||||
'flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-medium text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let { class: className = '', ...rest } = $props();
|
||||
</script>
|
||||
|
||||
<img class={cn('aspect-square h-full w-full', className)} {...rest} />
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Avatar } from './Avatar.svelte';
|
||||
export { default as AvatarFallback } from './AvatarFallback.svelte';
|
||||
export { default as AvatarImage } from './AvatarImage.svelte';
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Variant = 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
|
||||
const base =
|
||||
'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';
|
||||
|
||||
const variantClasses: Record<Variant, string> = {
|
||||
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',
|
||||
};
|
||||
|
||||
let {
|
||||
variant = 'default',
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
variant?: Variant;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="badge" class={cn(base, variantClasses[variant], className)} {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Badge } from './Badge.svelte';
|
||||
@@ -0,0 +1,3 @@
|
||||
<nav aria-label="breadcrumb" class="text-sm">
|
||||
<slot />
|
||||
</nav>
|
||||
@@ -0,0 +1,3 @@
|
||||
<li class="inline-flex items-center gap-1.5">
|
||||
<slot />
|
||||
</li>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
children,
|
||||
asChild = false,
|
||||
class: className = '',
|
||||
...rest
|
||||
}: {
|
||||
children?: Snippet<[Record<string, unknown>]>;
|
||||
asChild?: boolean;
|
||||
class?: string;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
|
||||
const classes = () => cn('transition-colors hover:text-foreground', className);
|
||||
</script>
|
||||
|
||||
{#if asChild}
|
||||
{@render children?.({ class: classes(), ...rest })}
|
||||
{:else}
|
||||
<a class={classes()} {...rest}>
|
||||
{@render children?.({})}
|
||||
</a>
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
<ol class="flex flex-wrap items-center gap-1.5">
|
||||
<slot />
|
||||
</ol>
|
||||
@@ -0,0 +1,3 @@
|
||||
<span class="font-medium text-foreground">
|
||||
<slot />
|
||||
</span>
|
||||
@@ -0,0 +1 @@
|
||||
<span class="text-muted-foreground">/</span>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Breadcrumb } from './Breadcrumb.svelte';
|
||||
export { default as BreadcrumbItem } from './BreadcrumbItem.svelte';
|
||||
export { default as BreadcrumbLink } from './BreadcrumbLink.svelte';
|
||||
export { default as BreadcrumbList } from './BreadcrumbList.svelte';
|
||||
export { default as BreadcrumbPage } from './BreadcrumbPage.svelte';
|
||||
export { default as BreadcrumbSeparator } from './BreadcrumbSeparator.svelte';
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Variant =
|
||||
| 'default'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'destructive'
|
||||
| 'outline'
|
||||
| 'link';
|
||||
type Size = 'default' | 'sm' | 'lg' | 'icon';
|
||||
type AsChildProps = {
|
||||
class?: string;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
};
|
||||
|
||||
let {
|
||||
children,
|
||||
asChild = false,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
class: className = '',
|
||||
type = 'button',
|
||||
...rest
|
||||
}: {
|
||||
children?: Snippet<[AsChildProps]>;
|
||||
asChild?: boolean;
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
class?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
|
||||
const classes = () => cn(base, variants[variant], sizes[size], className);
|
||||
</script>
|
||||
|
||||
{#if asChild}
|
||||
{@render children?.({ class: classes(), ...rest })}
|
||||
{:else}
|
||||
<button class={classes()} type={type} {...rest}>
|
||||
{@render children?.({})}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="card" class={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="card-action" class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="card-content" class={cn('px-6', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<p data-slot="card-description" class={cn('text-muted-foreground text-sm', className)}>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="card-footer" class={cn('flex items-center px-6 [.border-t]:pt-6', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<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',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<h3 data-slot="card-title" class={cn('leading-none font-semibold', className)}>
|
||||
{@render children?.()}
|
||||
</h3>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from './Card.svelte';
|
||||
export { default as CardAction } from './CardAction.svelte';
|
||||
export { default as CardContent } from './CardContent.svelte';
|
||||
export { default as CardDescription } from './CardDescription.svelte';
|
||||
export { default as CardFooter } from './CardFooter.svelte';
|
||||
export { default as CardHeader } from './CardHeader.svelte';
|
||||
export { default as CardTitle } from './CardTitle.svelte';
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
|
||||
let {
|
||||
checked = $bindable(false),
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
id,
|
||||
name,
|
||||
value,
|
||||
...rest
|
||||
}: {
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
class?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
value?: string;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
data-state={checked ? 'checked' : 'unchecked'}
|
||||
data-slot="checkbox"
|
||||
{disabled}
|
||||
{id}
|
||||
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-lg border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
onclick={() => { if (!disabled) checked = !checked; }}
|
||||
{...rest}
|
||||
>
|
||||
{#if checked}
|
||||
<div data-slot="checkbox-indicator" class="grid place-content-center text-current transition-none">
|
||||
<Check class="size-3.5" />
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if name}
|
||||
<input type="hidden" {name} {value} />
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from './Checkbox.svelte';
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
disabled = false,
|
||||
children,
|
||||
}: {
|
||||
open?: boolean;
|
||||
disabled?: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="collapsible" data-state={open ? 'open' : 'closed'} data-disabled={disabled || undefined}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
children?: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="collapsible-content" {...rest}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
children?: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button type="button" data-slot="collapsible-trigger" {...rest}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Collapsible } from './Collapsible.svelte';
|
||||
export { default as CollapsibleContent } from './CollapsibleContent.svelte';
|
||||
export { default as CollapsibleTrigger } from './CollapsibleTrigger.svelte';
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { setContext } from 'svelte';
|
||||
import { DIALOG_CONTEXT, type DialogContext } from './context';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onOpenChange,
|
||||
children,
|
||||
}: { open?: boolean; onOpenChange?: (value: boolean) => void; children?: Snippet } = $props();
|
||||
|
||||
const context: DialogContext = {
|
||||
open: () => open,
|
||||
setOpen: (value: boolean) => {
|
||||
open = value;
|
||||
onOpenChange?.(value);
|
||||
},
|
||||
};
|
||||
|
||||
setContext(DIALOG_CONTEXT, context);
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { DIALOG_CONTEXT, type DialogContext } from './context';
|
||||
|
||||
let { asChild = false, children }: { asChild?: boolean; children?: Snippet<[Record<string, unknown>]> } = $props();
|
||||
|
||||
const { setOpen } = getContext<DialogContext>(DIALOG_CONTEXT);
|
||||
|
||||
const handleClick = () => setOpen(false);
|
||||
</script>
|
||||
|
||||
{#if asChild}
|
||||
{@render children?.({ onClick: handleClick })}
|
||||
{:else}
|
||||
<button type="button" onclick={handleClick}>
|
||||
{@render children?.({})}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DIALOG_CONTEXT, type DialogContext } from './context';
|
||||
|
||||
let { class: className = '', children }: { class?: string; children?: Snippet } = $props();
|
||||
|
||||
const { open, setOpen } = getContext<DialogContext>(DIALOG_CONTEXT);
|
||||
|
||||
const close = () => setOpen(false);
|
||||
</script>
|
||||
|
||||
{#if open()}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 bg-black/50"
|
||||
aria-label="Close"
|
||||
onclick={close}
|
||||
></button>
|
||||
<div
|
||||
class={cn(
|
||||
'relative z-10 w-full max-w-lg rounded-lg border bg-background p-6 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
<p class="text-sm text-muted-foreground">
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<h2 class="text-lg font-semibold leading-none tracking-tight">
|
||||
<slot />
|
||||
</h2>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { DIALOG_CONTEXT, type DialogContext } from './context';
|
||||
|
||||
let { asChild = false, children }: { asChild?: boolean; children?: Snippet<[Record<string, unknown>]> } = $props();
|
||||
|
||||
const { setOpen, open } = getContext<DialogContext>(DIALOG_CONTEXT);
|
||||
|
||||
const handleClick = () => setOpen(true);
|
||||
</script>
|
||||
|
||||
{#if asChild}
|
||||
{@render children?.({ onClick: handleClick, 'aria-expanded': open() })}
|
||||
{:else}
|
||||
<button type="button" onclick={handleClick} aria-expanded={open()}>
|
||||
{@render children?.({})}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type DialogContext = {
|
||||
open: () => boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DIALOG_CONTEXT = Symbol('dialog');
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as Dialog } from './Dialog.svelte';
|
||||
export { default as DialogClose } from './DialogClose.svelte';
|
||||
export { default as DialogContent } from './DialogContent.svelte';
|
||||
export { default as DialogDescription } from './DialogDescription.svelte';
|
||||
export { default as DialogFooter } from './DialogFooter.svelte';
|
||||
export { default as DialogTitle } from './DialogTitle.svelte';
|
||||
export { default as DialogTrigger } from './DialogTrigger.svelte';
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount, setContext } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DROPDOWN_MENU_CONTEXT, type DropdownMenuContext } from './context';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
let menuElement: HTMLDivElement | null = null;
|
||||
|
||||
const context: DropdownMenuContext = {
|
||||
open: () => open,
|
||||
setOpen: (value: boolean) => {
|
||||
open = value;
|
||||
},
|
||||
};
|
||||
|
||||
setContext(DROPDOWN_MENU_CONTEXT, context);
|
||||
|
||||
onMount(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!open || !menuElement) return;
|
||||
|
||||
const target = event.target as Node | null;
|
||||
if (target && !menuElement.contains(target)) {
|
||||
open = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handlePointerDown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={cn('relative', className)} bind:this={menuElement}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DROPDOWN_MENU_CONTEXT, type DropdownMenuContext } from './context';
|
||||
|
||||
let {
|
||||
align = 'start',
|
||||
side = 'bottom',
|
||||
sideOffset = 0,
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
align?: 'start' | 'center' | 'end';
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
sideOffset?: number;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const { open, setOpen } = getContext<DropdownMenuContext>(DROPDOWN_MENU_CONTEXT);
|
||||
|
||||
const alignClasses: Record<string, string> = {
|
||||
start: 'left-0',
|
||||
center: 'left-1/2 -translate-x-1/2',
|
||||
end: 'right-0',
|
||||
};
|
||||
|
||||
const sideClasses: Record<string, string> = {
|
||||
bottom: 'top-full',
|
||||
top: 'bottom-full',
|
||||
left: 'right-full',
|
||||
right: 'left-full',
|
||||
};
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
const offsetStyle = () => {
|
||||
switch (side) {
|
||||
case 'top':
|
||||
return `margin-bottom: ${sideOffset}px;`;
|
||||
case 'left':
|
||||
return `margin-right: ${sideOffset}px;`;
|
||||
case 'right':
|
||||
return `margin-left: ${sideOffset}px;`;
|
||||
default:
|
||||
return `margin-top: ${sideOffset}px;`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if open()}
|
||||
<div
|
||||
class={cn(
|
||||
'absolute z-50 min-w-48 rounded-md border bg-popover p-2 text-popover-foreground shadow-md',
|
||||
alignClasses[align] ?? alignClasses.start,
|
||||
sideClasses[side] ?? sideClasses.bottom,
|
||||
className,
|
||||
)}
|
||||
style={offsetStyle()}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
onkeydown={(event) => event.key === 'Escape' && close()}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
<div class="flex flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DROPDOWN_MENU_CONTEXT, type DropdownMenuContext } from './context';
|
||||
|
||||
type AsChildProps = {
|
||||
class?: string;
|
||||
onClick?: () => void;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
let {
|
||||
asChild = false,
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
asChild?: boolean;
|
||||
class?: string;
|
||||
children?: Snippet<[AsChildProps]>;
|
||||
} = $props();
|
||||
|
||||
const { setOpen } = getContext<DropdownMenuContext>(DROPDOWN_MENU_CONTEXT);
|
||||
|
||||
const handleClick = () => setOpen(false);
|
||||
|
||||
const classes = () =>
|
||||
cn(
|
||||
'flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground',
|
||||
className,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if asChild}
|
||||
{@render children?.({ class: classes(), onClick: handleClick })}
|
||||
{:else}
|
||||
<button type="button" class={classes()} onclick={handleClick}>
|
||||
{@render children?.({})}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
let { class: className = '', children }: { class?: string; children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn('px-2 py-1.5 text-sm font-semibold', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { getContext } from 'svelte';
|
||||
import { DROPDOWN_MENU_CONTEXT, type DropdownMenuContext } from './context';
|
||||
|
||||
type TriggerProps = {
|
||||
onclick?: (event: MouseEvent) => void;
|
||||
'aria-expanded'?: boolean;
|
||||
'data-state'?: 'open' | 'closed';
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
let {
|
||||
asChild = false,
|
||||
children,
|
||||
}: { asChild?: boolean; children?: Snippet<[TriggerProps]> } = $props();
|
||||
|
||||
const { open, setOpen } = getContext<DropdownMenuContext>(DROPDOWN_MENU_CONTEXT);
|
||||
|
||||
const handleClick = () => setOpen(!open());
|
||||
</script>
|
||||
|
||||
{#if asChild}
|
||||
{@render children?.({ onclick: handleClick, 'aria-expanded': open(), 'data-state': open() ? 'open' : 'closed' })}
|
||||
{:else}
|
||||
<button type="button" onclick={handleClick} aria-expanded={open()}>
|
||||
{@render children?.({})}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type DropdownMenuContext = {
|
||||
open: () => boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
};
|
||||
|
||||
export const DROPDOWN_MENU_CONTEXT = Symbol('dropdown-menu');
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as DropdownMenu } from './DropdownMenu.svelte';
|
||||
export { default as DropdownMenuContent } from './DropdownMenuContent.svelte';
|
||||
export { default as DropdownMenuGroup } from './DropdownMenuGroup.svelte';
|
||||
export { default as DropdownMenuItem } from './DropdownMenuItem.svelte';
|
||||
export { default as DropdownMenuLabel } from './DropdownMenuLabel.svelte';
|
||||
export { default as DropdownMenuSeparator } from './DropdownMenuSeparator.svelte';
|
||||
export { default as DropdownMenuTrigger } from './DropdownMenuTrigger.svelte';
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { setInputOTPContext } from './context';
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
maxlength = 6,
|
||||
disabled = false,
|
||||
id,
|
||||
class: className = '',
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
value?: string;
|
||||
maxlength?: number;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
|
||||
let inputRef: HTMLInputElement;
|
||||
let isFocused = $state(false);
|
||||
const activeIndex = $derived(Math.min(value.length, maxlength - 1));
|
||||
|
||||
setInputOTPContext({
|
||||
value: () => value,
|
||||
activeIndex: () => (isFocused ? activeIndex : -1),
|
||||
isFocused: () => isFocused,
|
||||
focus: () => inputRef?.focus(),
|
||||
});
|
||||
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
value = target.value.replace(/[^0-9]/g, '').slice(0, maxlength);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Backspace' && value.length > 0) {
|
||||
e.preventDefault();
|
||||
value = value.slice(0, -1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-slot="input-otp"
|
||||
class={cn('relative flex items-center gap-2 has-disabled:opacity-50', className)}
|
||||
role="group"
|
||||
{...rest}
|
||||
>
|
||||
<input
|
||||
bind:this={inputRef}
|
||||
{id}
|
||||
{disabled}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
onfocus={() => (isFocused = true)}
|
||||
onblur={() => (isFocused = false)}
|
||||
class="absolute inset-0 z-10 h-full w-full cursor-text opacity-0 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div class="pointer-events-none">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
children,
|
||||
}: {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div data-slot="input-otp-group" class={cn('flex items-center', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
</script>
|
||||
|
||||
<div data-slot="input-otp-separator" role="separator">
|
||||
<Minus />
|
||||
</div>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getInputOTPContext } from './context';
|
||||
|
||||
let {
|
||||
index,
|
||||
class: className = '',
|
||||
}: {
|
||||
index: number;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const ctx = getInputOTPContext();
|
||||
|
||||
const char = $derived(ctx.value()[index]);
|
||||
const isActive = $derived(ctx.isFocused() && ctx.activeIndex() === index);
|
||||
const hasFakeCaret = $derived(isActive && !char);
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-slot="input-otp-slot"
|
||||
data-active={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]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{char ?? ''}
|
||||
{#if hasFakeCaret}
|
||||
<div 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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
|
||||
export const INPUT_OTP_CONTEXT = Symbol('input-otp');
|
||||
|
||||
export type InputOTPContext = {
|
||||
value: () => string;
|
||||
activeIndex: () => number;
|
||||
isFocused: () => boolean;
|
||||
focus: () => void;
|
||||
};
|
||||
|
||||
export function setInputOTPContext(ctx: InputOTPContext): void {
|
||||
setContext(INPUT_OTP_CONTEXT, ctx);
|
||||
}
|
||||
|
||||
export function getInputOTPContext(): InputOTPContext {
|
||||
return getContext<InputOTPContext>(INPUT_OTP_CONTEXT);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as InputOTP } from './InputOTP.svelte';
|
||||
export { default as InputOTPGroup } from './InputOTPGroup.svelte';
|
||||
export { default as InputOTPSeparator } from './InputOTPSeparator.svelte';
|
||||
export { default as InputOTPSlot } from './InputOTPSlot.svelte';
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
let { class: className = '', type = 'text', ...rest } = $props();
|
||||
</script>
|
||||
|
||||
<input
|
||||
type={type}
|
||||
class={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from './Input.svelte';
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLLabelAttributes } from 'svelte/elements';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = HTMLLabelAttributes & { children?: Snippet };
|
||||
|
||||
let { class: className = '', children, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label
|
||||
class={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{@render children?.()}
|
||||
</label>
|
||||
Loaded 100 of 418 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user