first commit
linter / quality (push) Canceled after 0s
tests / ci (8.3) (push) Canceled after 0s
tests / ci (8.4) (push) Canceled after 0s
tests / ci (8.5) (push) Canceled after 0s

This commit is contained in:
salam committed 2026-10-06 18:53:03 +02:00
commit e687b4437b
418 files changed
+33577

No files matched your search

+172
View File
@@ -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;
}
}
+33
View File
@@ -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();
+26
View File
@@ -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>
+31
View File
@@ -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}
+19
View File
@@ -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>
+281
View File
@@ -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>
+12
View File
@@ -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>
+28
View File
@@ -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}
+87
View File
@@ -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>
+92
View File
@@ -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>
+24
View File
@@ -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>
+15
View File
@@ -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}
+49
View File
@@ -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>
+49
View File
@@ -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>
+54
View File
@@ -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>
+32
View File
@@ -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>
+35
View File
@@ -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>
+197
View File
@@ -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>
+76
View File
@@ -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>
+123
View File
@@ -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>
+108
View File
@@ -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>
+82
View File
@@ -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>
+9
View File
@@ -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"
/>
+219
View File
@@ -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>
+146
View File
@@ -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>&copy 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>
+7
View File
@@ -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