@ludoloops/svelteforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/dist/index-CqCposFO.d.ts +18 -0
- package/dist/index.js +270 -0
- package/package.json +49 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { defineAddon, defineAddonOptions } from "sv";
|
|
2
|
+
//#region src/templates.ts
|
|
3
|
+
const landingFiles = {
|
|
4
|
+
"/routes/+layout.svelte": "<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { Footer, Navbar } from '$lib/components';\n import { themeStore } from '$lib/utils/theme.svelte';\n import '../app.css';\n\n let { children } = $props();\n\n onMount(() => {\n themeStore.init();\n });\n<\/script>\n\n<div class=\"flex flex-col min-h-screen\">\n <Navbar />\n <main class=\"flex-1 pt-16\">{@render children()}</main>\n <Footer />\n</div>\n",
|
|
5
|
+
"/routes/+page.svelte": "<script lang=\"ts\">\n import Hero from '$lib/sections/Hero.svelte';\n import Stats from '$lib/sections/Stats.svelte';\n import Why from '$lib/sections/Why.svelte';\n import Stack from '$lib/sections/Stack.svelte';\n import UseCase from '$lib/sections/UseCase.svelte';\n import Include from '$lib/sections/Include.svelte';\n import CTA from '$lib/sections/CTA.svelte';\n import QuickStart from '$lib/sections/QuickStart.svelte';\n<\/script>\n\n<svelte:head>\n <title>SvelteForge — Production-Ready SvelteKit Starter Kit</title>\n <meta\n name=\"description\"\n content=\"Complete, reusable SvelteKit boilerplate with authentication, database, and 30+ UI components. Save 20+ hours on every new project.\"\n />\n</svelte:head>\n\n<div class=\"relative min-h-screen\">\n <main>\n <Hero />\n <Stats />\n <Why />\n <Stack />\n <UseCase />\n <Include />\n <CTA />\n <QuickStart />\n </main>\n</div>\n",
|
|
6
|
+
"/lib/components/Logo.svelte": "<script lang=\"ts\">\n import { cn } from '$lib/components/ui/utils/cn';\n\n const { class: className = '' }: { class?: string } = $props();\n<\/script>\n\n<span class={cn('logo text-4xl font-extrabold', className)}>SvelteForge</span>\n\n<style>\n @keyframes metalFlow {\n 0% {\n background-position: 0% 50%;\n }\n 50% {\n background-position: 100% 50%;\n }\n 100% {\n background-position: 0% 50%;\n }\n }\n .logo {\n font-family: 'Nunito Variable', sans-serif;\n color: transparent;\n background: linear-gradient(\n 45deg,\n var(--color-secondary-200-800),\n var(--color-secondary-500),\n var(--color-secondary-600-400),\n var(--color-secondary-200-800)\n );\n background-size: 200% 200%;\n -webkit-background-clip: text;\n background-clip: text;\n letter-spacing: 0.05em;\n animation: metalFlow 5s ease-in-out infinite;\n filter: drop-shadow(0 0 2px var(--color-error-200-800));\n }\n</style>\n",
|
|
7
|
+
"/lib/components/anim/FlyIn.svelte": "<script lang=\"ts\">\n import { onMount, type Snippet } from 'svelte';\n import { cubicOut } from 'svelte/easing';\n import { fly } from 'svelte/transition';\n\n type Direction = 'up' | 'down' | 'left' | 'right';\n\n const {\n direction = 'up',\n distance = 30,\n duration = 0.9,\n delay = 0.2,\n easing = cubicOut,\n once = false,\n children\n }: {\n direction?: Direction;\n distance?: number;\n duration?: number;\n delay?: number;\n easing?: (t: number) => number;\n once?: boolean;\n children: Snippet;\n } = $props();\n\n let element: HTMLElement;\n let visible = $state(false);\n let hasAnimated = $state(false);\n\n function getTransitionParams(): Parameters<typeof fly>[1] {\n const params: Record<string, any> = {\n duration: duration * 1000,\n delay: delay * 1000,\n easing\n };\n\n switch (direction) {\n case 'up':\n params.y = distance;\n break;\n case 'down':\n params.y = -distance;\n break;\n case 'left':\n params.x = -distance;\n break;\n case 'right':\n params.x = distance;\n break;\n }\n\n return params;\n }\n\n onMount(() => {\n if (!element) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n const [entry] = entries;\n\n if (entry.isIntersecting && (!hasAnimated || !once)) {\n visible = true;\n hasAnimated = true;\n\n if (once) {\n observer.disconnect();\n }\n } else if (!entry.isIntersecting && !once) {\n visible = false;\n }\n },\n {\n threshold: 0.1,\n rootMargin: '0px 0px -10% 0px'\n }\n );\n\n observer.observe(element);\n\n return () => observer.disconnect();\n });\n<\/script>\n\n<div bind:this={element}>\n {#if visible}\n <div in:fly|local={getTransitionParams()}>\n {@render children()}\n </div>\n {:else}\n <div style=\"opacity: 0;\">\n {@render children()}\n </div>\n {/if}\n</div>\n",
|
|
8
|
+
"/lib/components/anim/DynamicCounter.svelte": "<script lang=\"ts\">\n import { onMount } from 'svelte';\n\n const {\n target = 0,\n duration = 2,\n suffix = '',\n prefix = '',\n once = true\n }: {\n target: number;\n duration?: number;\n suffix?: string;\n prefix?: string;\n once?: boolean;\n } = $props();\n\n let container: HTMLElement;\n let currentValue = $state(0);\n let hasAnimated = $state(false);\n\n onMount(() => {\n if (!container) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n const entry = entries[0];\n if (entry.isIntersecting && (!hasAnimated || !once)) {\n hasAnimated = true;\n\n const startTime = performance.now();\n const animate = () => {\n const elapsed = performance.now() - startTime;\n const progress = Math.min(elapsed / (duration * 1000), 1);\n const easedProgress = 1 - Math.pow(1 - progress, 3);\n currentValue = Math.round(easedProgress * target);\n\n if (progress < 1) {\n requestAnimationFrame(animate);\n }\n };\n\n requestAnimationFrame(animate);\n\n if (once) {\n observer.disconnect();\n }\n } else if (!entry.isIntersecting && !once) {\n currentValue = 0;\n }\n },\n {\n threshold: 0.1,\n rootMargin: '0px 0px -10% 0px'\n }\n );\n\n observer.observe(container);\n return () => observer.disconnect();\n });\n<\/script>\n\n<span bind:this={container}>{prefix}{currentValue}{suffix}</span>\n",
|
|
9
|
+
"/lib/components/layout/navbar.svelte": "<script lang=\"ts\">\n import { AppBar } from '@skeletonlabs/skeleton-svelte';\n import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';\n import { themeStore } from '$lib/utils/theme.svelte';\n import { onMount, onDestroy } from 'svelte';\n\n let mobileMenuOpen = $state(false);\n\n onMount(() => {\n themeStore.init();\n });\n onDestroy(() => {\n themeStore.destroy();\n });\n<\/script>\n\n{#if mobileMenuOpen}\n <div\n class=\"md:hidden fixed inset-0 top-16 bg-surface-50-900 z-40\"\n onclick={() => (mobileMenuOpen = false)}\n onkeydown={(e) => e.key === 'Escape' && (mobileMenuOpen = false)}\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex=\"-1\"\n >\n <div class=\"flex flex-col p-6\">\n <button\n type=\"button\"\n onclick={() => {\n themeStore.toggle();\n mobileMenuOpen = false;\n }}\n class=\"flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-surface-200-800 text-sm\"\n >\n {themeStore.isDark ? 'Light Mode' : 'Dark Mode'}\n </button>\n </div>\n </div>\n{/if}\n\n<AppBar>\n <AppBar.Toolbar class=\"grid-cols-[1fr_auto_1fr]\">\n <AppBar.Lead>\n <a\n href=\"/\"\n class=\"text-xl font-bold text-surface-50-950 hover:text-primary-400-500 transition-colors\"\n >\n __PROJECT_NAME__\n </a>\n </AppBar.Lead>\n\n <AppBar.Headline />\n\n <AppBar.Trail>\n <div class=\"hidden md:flex items-center gap-2\">\n <ThemeToggle />\n </div>\n\n <button\n onclick={() => (mobileMenuOpen = !mobileMenuOpen)}\n class=\"md:hidden btn-icon text-surface-50-950\"\n aria-label=\"Menu\"\n >\n {#if mobileMenuOpen}\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M18 6L6 18M6 6l12 12\" />\n </svg>\n {:else}\n <svg\n width=\"24\"\n height=\"24\"\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"2\"\n >\n <path d=\"M3 12h18M3 6h18M3 18h18\" />\n </svg>\n {/if}\n </button>\n </AppBar.Trail>\n </AppBar.Toolbar>\n</AppBar>\n",
|
|
10
|
+
"/lib/data/links.ts": "const links = {\n githubForge: {\n name: 'GitHub',\n href: 'https://github.com/lelabdev/svelteForge'\n },\n githubLudo: {\n name: 'GitHub',\n href: 'https://github.com/LudoLoops'\n },\n betterAuth: {\n name: 'Better Auth',\n href: 'https://better-auth.com'\n },\n shiki: {\n name: 'Shiki',\n href: 'https://shiki.style/'\n },\n kofi: {\n name: 'ko-fi',\n href: 'https://ko-fi.com/ludoloops'\n },\n lelab: {\n name: 'LeLab.dev',\n href: 'https://lelab.dev'\n },\n ludoloops: {\n name: 'LudoLoops',\n href: 'https://ludoloops.dev/'\n }\n};\nexport default links;\n",
|
|
11
|
+
"/lib/sections/QuickStart.svelte": "<script lang=\"ts\">\n import { Badge, Card } from '$lib/components/ui';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n const steps = [\n {\n number: '1',\n title: 'Create',\n description: 'One CLI command. Choose Landing or Full Stack.',\n icon: 'plus-circle'\n },\n {\n number: '2',\n title: 'Configure',\n description: 'Setup script generates .env, DB, admin. Zero files to edit.',\n icon: 'wrench'\n },\n {\n number: '3',\n title: 'Ship',\n description: 'Everything wired. Start coding what makes your project unique.',\n icon: 'rocket-launch'\n }\n ];\n\n const commands = [\n 'bunx sv create my-project --template minimal --types ts --add tailwindcss',\n 'cd my-project',\n 'bunx sv add @lelabdev/svelteforge=template:landing',\n 'bun run dev'\n ];\n<\/script>\n\n<section class=\"bg-surface-100-900 px-6 py-20\">\n <div class=\"mx-auto max-w-5xl\">\n <div class=\"mb-12 text-center\">\n <h2 class=\"title mb-4 text-4xl font-bold text-surface-950-50\">How it works</h2>\n <p class=\"subtitle text-xl text-surface-600-400\">Three steps. Not thirty.</p>\n </div>\n\n <!-- 3 Steps -->\n <div class=\"mb-12 grid gap-8 sm:grid-cols-3\">\n {#each steps as step (step)}\n <div class=\"text-center\">\n <div\n class=\"mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary-500 text-xl font-bold text-white\"\n >\n {step.number}\n </div>\n <h3 class=\"title mb-2 text-xl font-semibold text-surface-950-50\">\n {step.title}\n </h3>\n <p class=\"subtitle text-surface-600-400\">{step.description}</p>\n </div>\n {/each}\n </div>\n\n <!-- Code Block -->\n <Card variant=\"flat\" class=\"bg-surface-800-900 p-0\" noPadding>\n <div class=\"p-8 font-mono text-sm text-surface-200-700\">\n {#each commands as cmd, i}\n <div class=\"mb-2 flex items-center gap-2\">\n <span class=\"text-surface-500\">➜</span>\n <span class=\"text-primary-400\">$</span>\n <span>{cmd}</span>\n </div>\n {/each}\n </div>\n </Card>\n\n <!-- Template Badges -->\n <div class=\"mt-12 text-center\">\n <p class=\"subtitle mb-6 text-lg text-surface-600-400\">Choose your template:</p>\n <div class=\"flex flex-wrap justify-center gap-3\">\n <Badge variant=\"primary\">Full Stack — Auth, DB, 40+ components</Badge>\n <Badge variant=\"surface\">Landing — Minimal, UI only</Badge>\n </div>\n </div>\n </div>\n</section>\n",
|
|
12
|
+
"/lib/sections/Stats.svelte": "<script lang=\"ts\">\n import { Card } from '$lib/components/ui';\n import DynamicCounter from '$lib/components/anim/DynamicCounter.svelte';\n\n const stats = [\n { label: 'Hours Saved', suffix: '+', value: 20, description: 'On every new project', color: 'primary' },\n { label: 'Components', suffix: '+', value: 30, description: 'Ready to use', color: 'secondary' },\n { label: 'Lines of Code', suffix: '+', value: 5000, description: 'Pre-written for you', color: 'tertiary' },\n { label: 'Time to Deploy', value: 10, prefix: '<', suffix: 'min', description: 'From clone to production', color: 'success' }\n ];\n<\/script>\n\n<section class=\"relative bg-surface-100-900 px-6 py-16\">\n <div class=\"mx-auto max-w-7xl\">\n <div class=\"mb-12 text-center\">\n <h2 class=\"title mb-3 text-2xl font-bold text-surface-950-50 sm:text-3xl\">You know the routine.</h2>\n <p class=\"subtitle text-surface-600-400\">Every project, the same thing.</p>\n </div>\n\n <div class=\"grid gap-8 sm:grid-cols-2 lg:grid-cols-4\">\n {#each stats as stat, i (stat)}\n <Card variant=\"flat\" class=\"border-t-4 p-8 text-center border-{stat.color}-500\">\n <span class=\"title mb-2 block text-4xl font-bold text-{stat.color}-500\">\n {stat.prefix ?? ''}<DynamicCounter suffix={stat.suffix} target={stat.value} duration={3} />\n </span>\n <div class=\"mb-1 text-xl font-semibold text-surface-900-50\">{stat.label}</div>\n <div class=\"text-sm text-surface-600-400\">{stat.description}</div>\n </Card>\n {/each}\n </div>\n\n <div class=\"mt-10 text-center\">\n <p class=\"subtitle mb-4 text-lg text-surface-600-400\">\n 15 hours rebuilding what already exists. And you haven't even started your product.\n </p>\n <p class=\"title text-xl font-bold text-primary-500\">\n What if all of this took 2 minutes?\n </p>\n </div>\n </div>\n</section>\n",
|
|
13
|
+
"/lib/sections/UseCase.svelte": "<script>\n import { Card } from '$lib/components/ui';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n const points = [\n {\n icon: 'shieldCheck',\n title: 'MIT license — use it however you want',\n borderColor: 'border-primary-500',\n iconBg: 'bg-primary-400-600/40',\n textColor: 'text-primary-500'\n },\n {\n icon: 'arrowSquareOut',\n title: 'No vendor lock-in — it\\'s your codebase',\n borderColor: 'border-secondary-500',\n iconBg: 'bg-secondary-400-600/40',\n textColor: 'text-secondary-500'\n },\n {\n icon: 'clock',\n title: 'Built on sv create — you follow SvelteKit, not a fork',\n borderColor: 'border-tertiary-500',\n iconBg: 'bg-tertiary-400-600/40',\n textColor: 'text-tertiary-500'\n },\n {\n icon: 'airplane',\n title: 'Svelte community — one niche, done right',\n borderColor: 'border-success-500',\n iconBg: 'bg-success-400-600/40',\n textColor: 'text-success-500'\n }\n ];\n<\/script>\n\n<section class=\"relative bg-surface-50 px-6 py-20\">\n <div class=\"mx-auto max-w-4xl\">\n <div class=\"mb-16 text-center\">\n <h2 class=\"title mb-4 text-4xl font-bold text-surface-950-50\">Open source. For real.</h2>\n <p class=\"subtitle text-xl text-surface-600\">\n No hidden freemium, no surprise at upgrade time. The code is there, it stays there.\n </p>\n </div>\n\n <div class=\"grid gap-6 sm:grid-cols-2\">\n {#each points as point (point)}\n <Card variant=\"flat\" class=\"border-l-4 {point.borderColor} bg-surface-100-900 p-6 transition-all hover:scale-105 hover:shadow-lg\">\n <div class=\"flex items-start gap-4\">\n <div class=\"rounded-lg {point.iconBg} p-3\">\n <Icon name={point.icon} size={24} class={point.textColor} />\n </div>\n <p class=\"subtitle text-lg font-medium text-surface-950-50\">{point.title}</p>\n </div>\n </Card>\n {/each}\n </div>\n </div>\n</section>\n",
|
|
14
|
+
"/lib/sections/Include.svelte": "<script lang=\"ts\">\n import { Card } from '$lib/components/ui';\n\n // Define the data structure for the cards\n type IncludeItem = {\n title: string;\n description: string;\n };\n\n // Items to keep\n const keepItems: IncludeItem[] = [\n {\n title: 'Authentication System',\n description: '- /login, /register, hooks.server.ts'\n },\n {\n title: 'UI Component Library',\n description: '- src/lib/components/ui/'\n },\n {\n title: 'Database Setup',\n description: '- src/lib/server/db/'\n },\n {\n title: 'Security Helpers',\n description: '- src/lib/server/security.ts'\n },\n {\n title: 'Type Definitions',\n description: '- src/app.d.ts'\n }\n ];\n\n // Items to delete\n const deleteItems: IncludeItem[] = [\n {\n title: '/demo-ui route',\n description: '- Delete after exploring components'\n },\n {\n title: '/carta route',\n description: '- Delete if not using Markdown'\n },\n {\n title: '/dashboard & /admin',\n description: '- Example protected routes'\n },\n {\n title: 'src/lib/components/Carta/',\n description: '- If not using Markdown editor'\n },\n {\n title: 'src/lib/components/SortDnD/',\n description: '- If not using drag & drop'\n }\n ];\n<\/script>\n\n<section class=\"bg-surface-100 px-6 py-20\">\n <div class=\"mx-auto max-w-7xl\">\n <div class=\"grid gap-12 lg:grid-cols-2\">\n <!-- What to Keep column -->\n <div>\n <div class=\"mb-6 flex items-center gap-3\">\n <div class=\"rounded-full bg-success-500/20 p-2\">\n <div class=\"h-3 w-3 rounded-full bg-success-500\"></div>\n </div>\n <h2 class=\"text-3xl font-bold text-surface-900-50\">✅ What to Keep</h2>\n </div>\n <div class=\"space-y-3\">\n {#each keepItems as item, index (index)}\n <Card\n variant=\"flat\"\n class=\"border-l-4 border-success-500 bg-success-50-950/80 p-4 transition-all hover:scale-102 hover:shadow-md\"\n >\n <strong class=\"text-success-700-300\">{item.title}</strong>\n <span class=\"text-surface-600-400\">{item.description}</span>\n </Card>\n {/each}\n </div>\n </div>\n\n <!-- What to Delete column -->\n <div>\n <div class=\"mb-6 flex items-center gap-3\">\n <div class=\"rounded-full bg-error-500/20 p-2\">\n <div class=\"h-3 w-3 rounded-full bg-error-500\"></div>\n </div>\n <h2 class=\"text-3xl font-bold text-surface-950-50\">❌ What to Delete (Examples)</h2>\n </div>\n <div class=\"space-y-3\">\n {#each deleteItems as item, index (index)}\n <Card\n variant=\"flat\"\n class=\"border-l-4 border-error-500 bg-error-200-800/40 p-4 transition-all hover:scale-102 hover:shadow-md\"\n >\n <strong class=\"text-error-700-300\">{item.title}</strong>\n <span class=\"text-surface-600-400\">{item.description}</span>\n </Card>\n {/each}\n </div>\n </div>\n </div>\n </div>\n</section>\n",
|
|
15
|
+
"/lib/sections/Stack.svelte": "<script lang=\"ts\">\n import { Badge, Card, Progress } from '$lib/components/ui';\n\n const techStack = [\n { name: 'Svelte 5', version: 'Runes', color: 'bg-primary-500' },\n { name: 'SvelteKit', version: '2.x', color: 'bg-secondary-500' },\n { name: 'Skeleton UI', version: 'v4', color: 'bg-tertiary-500' },\n { name: 'Tailwind CSS', version: 'v4', color: 'bg-success-500' },\n { name: 'BetterAuth', version: '1.x', color: 'bg-warning-500' },\n { name: 'Drizzle ORM', version: 'SQLite', color: 'bg-error-500' }\n ] as const;\n<\/script>\n\n<section class=\"bg-surface-100-900 px-6 py-20\">\n <div class=\"mx-auto max-w-7xl\">\n <div class=\"mb-16 text-center\">\n <h2 class=\"title mb-4 text-4xl font-bold text-surface-950-50\">The stack</h2>\n <p class=\"subtitle text-xl text-surface-600-400\">Not the latest trend. The right choice.</p>\n </div>\n\n <div class=\"grid gap-6 sm:grid-cols-2 lg:grid-cols-3\">\n {#each techStack as tech, i (tech.name)}\n <Card variant=\"flat\" class=\"transition-all hover:scale-105\">\n <div class=\"p-6\">\n <div class=\"mb-4 flex items-center justify-between\">\n <h3 class=\"text-xl font-bold text-surface-950-50\">{tech.name}</h3>\n <Badge>{tech.version}</Badge>\n </div>\n <Progress value={100} class={tech.color} size=\"md\" />\n </div>\n </Card>\n {/each}\n </div>\n\n <div class=\"mt-12 text-center\">\n <p class=\"subtitle text-lg text-surface-600-400\">\n Plus: SuperForms, Zod v4, Tiptap, Pino, Lucide, Vitest, Playwright...\n </p>\n </div>\n </div>\n</section>\n",
|
|
16
|
+
"/lib/sections/CTA.svelte": "<script>\n import { Button } from '$lib/components/ui';\n import Icon from '$lib/components/icons/Icon.svelte';\n import links from '$lib/data/links';\n<\/script>\n\n<section class=\"relative overflow-hidden px-6 py-20\">\n <div class=\"absolute inset-0 bg-linear-to-r from-primary-500 via-secondary-500 to-tertiary-500\"></div>\n <div class=\"absolute inset-0 bg-linear-to-b from-transparent via-primary-600/20 to-secondary-700/30\"></div>\n\n <div class=\"relative mx-auto max-w-4xl text-center\">\n <h2 class=\"title mb-6 text-4xl font-bold text-white drop-shadow-lg\">\n Your next project deserves better than a copy-paste.\n </h2>\n <p class=\"subtitle mb-8 text-xl text-white/95 drop-shadow\">\n Start with a clean foundation. Focus on what matters: your product.\n </p>\n\n <div class=\"flex flex-wrap items-center justify-center gap-4\">\n <Button\n href={links.githubForge.href}\n target=\"_blank\"\n variant=\"cta\"\n class=\"bg-white px-8 py-4 text-lg font-semibold text-primary-600 shadow-xl hover:bg-surface-100 hover:shadow-2xl\"\n >\n <Icon name=\"gitPullRequest\" size={20} class=\"mr-2\" />\n Star on GitHub\n </Button>\n <Button\n href=\"/demo-ui\"\n variant=\"glass\"\n class=\"border-2 border-white/30 bg-white/10 px-8 py-4 text-lg font-semibold text-white backdrop-blur hover:bg-white/20\"\n >\n Explore Components\n </Button>\n </div>\n </div>\n</section>\n",
|
|
17
|
+
"/lib/sections/Why.svelte": "<script lang=\"ts\">\n import FlyIn from '$lib/components/anim/FlyIn.svelte';\n import { Badge, Card } from '$lib/components/ui';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n const features = [\n {\n icon: 'sailboat',\n title: 'Ready to ship in minutes',\n description:\n 'One CLI, one command, everything wired. Auth, DB, components, setup scripts — your project is alive in 2 minutes.',\n highlight: 'CLI',\n variant: 'primary',\n bgColor: 'bg-primary-500/10',\n iconColor: 'text-primary-500'\n },\n {\n icon: 'shield',\n title: 'Built on the official foundation',\n description:\n 'SvelteForge starts from sv create, the official SvelteKit tool. Not a fork, not a detour — you follow the ecosystem, not a single dev.',\n highlight: 'sv create',\n variant: 'secondary',\n bgColor: 'bg-secondary-500/10',\n iconColor: 'text-secondary-500'\n },\n {\n icon: 'checkCircle',\n title: 'Real auth, not a dummy',\n description:\n \"BetterAuth with roles, sessions, password reset. Not a toy system — a production-grade setup you won't need to rewrite.\",\n highlight: 'BetterAuth',\n variant: 'success',\n bgColor: 'bg-success-500/10',\n iconColor: 'text-success-500'\n },\n {\n icon: 'package',\n title: '40+ production-ready components',\n description:\n 'Skeleton UI + custom components: DataTable, RichTextEditor, Stepper, Toast... Code your feature, not your base UI.',\n highlight: '40+',\n variant: 'surface',\n bgColor: 'bg-surface-500/10',\n iconColor: 'text-surface-500'\n },\n {\n icon: 'code',\n title: 'Database included',\n description:\n 'SQLite + Drizzle ORM. Zero config. Setup creates the DB, tables, admin user. You jump straight into your schemas.',\n highlight: 'Drizzle',\n variant: 'warning',\n bgColor: 'bg-warning-500/10',\n iconColor: 'text-warning-500'\n },\n {\n icon: 'star',\n title: 'Your codebase, your rules',\n description:\n 'Skeleton is an npm package, not copy-paste. Custom oklch theme, CSS tokens, clean structure. No lock-in, no tech debt.',\n highlight: 'No lock-in',\n variant: 'error',\n bgColor: 'bg-error-500/10',\n iconColor: 'text-error-500'\n }\n ] as const;\n<\/script>\n\n<section class=\"relative bg-surface-100-900 px-6 py-20\">\n <div class=\"mx-auto max-w-7xl\">\n <div class=\"mb-16 text-center\">\n <h2 class=\"title mb-4 text-4xl font-bold text-surface-950-50\">\n Not a feature list. A foundation.\n </h2>\n <p class=\"subtitle mx-auto max-w-3xl text-xl text-surface-600-400\">\n Every decision has been made so you don't have to make it.\n </p>\n </div>\n\n <div class=\"grid gap-8 md:grid-cols-2 lg:grid-cols-3\">\n {#each features as feature, i (feature.title)}\n <FlyIn direction=\"up\" delay={i / 10}>\n <Card variant=\"flat\" class=\"relative overflow-hidden transition-all hover:scale-105 hover:shadow-xl\">\n <div class=\"absolute top-0 right-0 h-32 w-32 rounded-bl-full bg-linear-to-br from-{feature.variant === 'surface' ? 'surface' : feature.variant}-500/20 to-transparent\"></div>\n <div class=\"relative p-6\">\n <div class=\"mb-4 flex items-center justify-between\">\n <div class=\"rounded-lg {feature.bgColor} p-3\">\n <Icon name={feature.icon} size={24} class={feature.iconColor} />\n </div>\n <Badge variant={feature.variant}>{feature.highlight}</Badge>\n </div>\n <h3 class=\"title mb-3 text-xl font-bold text-surface-950-50\">{feature.title}</h3>\n <p class=\"subtitle text-surface-600-400\">{feature.description}</p>\n </div>\n </Card>\n </FlyIn>\n {/each}\n </div>\n </div>\n</section>\n",
|
|
18
|
+
"/lib/sections/Hero.svelte": "<script lang=\"ts\">\n import { Badge, Button } from '$lib/components/ui';\n import Icon from '$lib/components/icons/Icon.svelte';\n import Logo from '$lib/components/Logo.svelte';\n import links from '$lib/data/links';\n<\/script>\n\n<section class=\"relative overflow-hidden px-6 py-20 sm:py-32\">\n <div class=\"mx-auto max-w-7xl\">\n <div class=\"text-center\">\n <div class=\"mb-8 flex flex-wrap justify-center gap-4\">\n <Badge variant=\"primary\">Open Source</Badge>\n <Badge variant=\"success\">Free Forever</Badge>\n </div>\n <Logo class=\"text-5xl sm:text-7xl\" />\n\n <p class=\"title mx-auto mb-4 max-w-2xl text-2xl font-semibold text-primary-500\">\n Stop rebuilding. Start creating.\n </p>\n\n <p class=\"subtitle mx-auto mb-12 max-w-3xl text-lg leading-8 text-surface-600-400\">\n The SvelteKit boilerplate built on <strong>sv create</strong>, powered by Skeleton.\n Auth, DB, 40+ components — everything wired, nothing to hack.\n </p>\n\n <div class=\"flex flex-wrap items-center justify-center gap-4\">\n <Button\n variant=\"primary\"\n size=\"lg\"\n class=\"px-8 py-4 text-lg font-semibold\"\n onclick={() => navigator.clipboard.writeText('bunx create-svelteforge')}\n >\n 🚀 Get Started\n </Button>\n <Button\n variant=\"ghost\"\n size=\"lg\"\n href={links.githubForge.href}\n target=\"_blank\"\n class=\"px-8 py-4 text-lg font-semibold\"\n >\n <Icon name=\"code\" size={20} class=\"mr-2\" />\n View on GitHub\n </Button>\n </div>\n\n <p class=\"font-code mt-4 text-sm text-surface-500\">\n bunx create-svelteforge\n </p>\n </div>\n </div>\n</section>\n",
|
|
19
|
+
"/app.d.ts": "declare global {\n namespace App {\n interface Locals {}\n }\n}\nexport {};\n"
|
|
20
|
+
};
|
|
21
|
+
const fullstackFiles = {
|
|
22
|
+
"/routes/+page.server.ts": "import { redirect } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ parent }) => {\n const parentData = await parent();\n\n // Si connecté, on envoie directement au dashboard\n if (parentData.session?.user) {\n const user = parentData.session.user;\n if (user.role === 'admin') {\n redirect(302, '/admin');\n }\n redirect(302, '/dashboard');\n }\n\n // Non connecté → afficher la landing page\n};\n",
|
|
23
|
+
"/routes/(protected)/+layout.svelte": "<script lang=\"ts\">\n import type { LayoutData } from './$types';\n\n let { data, children }: { data: LayoutData; children: any } = $props();\n<\/script>\n\n{@render children()}\n",
|
|
24
|
+
"/routes/(protected)/logout/+page.server.ts": "import { redirect } from '@sveltejs/kit';\nimport type { Actions } from './$types';\nimport { auth } from '$lib/server/auth';\n\nexport const actions: Actions = {\n default: async (event) => {\n await auth.api.signOut({\n headers: event.request.headers\n });\n throw redirect(302, '/login');\n }\n};\n",
|
|
25
|
+
"/routes/(protected)/logout/page.server.test.ts": "/**\n * Logout Route Tests\n *\n * Tests that the logout action calls auth.api.signOut with request headers\n * and redirects to /login.\n */\nimport { describe, it, expect, vi } from 'vitest';\n\n// Mock @sveltejs/kit redirect\nvi.mock('@sveltejs/kit', () => ({\n redirect: (status: number, location: string) => {\n const err = new Error(`Redirect ${status} → ${location}`);\n (err as any).status = status;\n (err as any).location = location;\n throw err;\n }\n}));\n\n// Mock the auth module — hoisted before imports\nconst mockSignOut = vi.fn();\nvi.mock('$lib/server/auth', () => ({\n auth: {\n api: {\n signOut: mockSignOut\n }\n }\n}));\n\n// Dynamic import to avoid + prefix issues with module resolution\nconst mod = await import('./+page.server');\n\nfunction makeEvent(headers: Record<string, string> = {}) {\n return {\n request: { headers: new Headers(headers) }\n };\n}\n\ndescribe('logout +page.server.ts', () => {\n it('calls signOut with request headers', async () => {\n mockSignOut.mockResolvedValue(undefined);\n const event = makeEvent({ cookie: 'session=abc123' });\n\n await expect(\n mod.actions.default(event as any)\n ).rejects.toThrow('Redirect 302 → /login');\n\n expect(mockSignOut).toHaveBeenCalledWith({\n headers: event.request.headers\n });\n });\n\n it('redirects to /login after signOut', async () => {\n mockSignOut.mockResolvedValue(undefined);\n const event = makeEvent();\n\n await expect(\n mod.actions.default(event as any)\n ).rejects.toThrow('Redirect 302 → /login');\n });\n\n it('signOut failure still redirects', async () => {\n // When signOut rejects, the error propagates before redirect is thrown.\n // This documents current behaviour: signOut failure prevents redirect.\n mockSignOut.mockRejectedValueOnce(new Error('signOut failed'));\n\n const event = makeEvent();\n\n await expect(\n mod.actions.default(event as any)\n ).rejects.toThrow('signOut failed');\n\n // Restore default mock behaviour\n mockSignOut.mockResolvedValue(undefined);\n });\n});\n",
|
|
26
|
+
"/routes/(protected)/+layout.server.ts": "import { redirect } from '@sveltejs/kit';\nimport type { LayoutServerLoad } from './$types';\n\nexport const load: LayoutServerLoad = async ({ locals }) => {\n if (!locals.user) {\n throw redirect(303, '/login');\n }\n\n return {\n user: locals.user\n };\n};\n",
|
|
27
|
+
"/routes/(protected)/dashboard/+page.server.ts": "import type { PageServerLoad } from './$types';\n\nexport const load: PageServerLoad = async ({ parent }) => {\n const { user } = await parent();\n\n return {\n user\n };\n};\n",
|
|
28
|
+
"/routes/(protected)/dashboard/+page.svelte": "<script lang=\"ts\">\n import type { PageData } from './$types';\n\n let { data }: { data: PageData } = $props();\n\n const displayName = $derived(data.user.name ?? data.user.email);\n const initials = $derived(\n (data.user.name ?? data.user.email)\n .split(' ')\n .map((part: string) => part[0])\n .slice(0, 2)\n .join('')\n .toUpperCase()\n );\n<\/script>\n\n<svelte:head>\n <title>Dashboard — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <!-- Welcome -->\n <section class=\"flex flex-col gap-2\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">\n Welcome back, {displayName}\n </h1>\n <p class=\"text-surface-500\">Here's an overview of your account.</p>\n </section>\n\n <!-- Profile Card -->\n <div class=\"card\" style=\"max-width: 480px;\">\n <div class=\"card-content flex flex-col gap-6 p-6\">\n <!-- Avatar + Name Row -->\n <div class=\"flex items-center gap-4\">\n <div\n class=\"flex items-center justify-center rounded-full bg-primary-500\"\n style=\"width: 56px; height: 56px;\"\n >\n <span class=\"text-surface-50-950 font-semibold text-lg\">\n {initials}\n </span>\n </div>\n <div class=\"flex flex-col\">\n <span class=\"text-lg font-semibold text-surface-50-950\">\n {displayName}\n </span>\n <span class=\"text-surface-500 text-sm\">\n {data.user.email}\n </span>\n </div>\n </div>\n\n <!-- Divider -->\n <hr class=\"border-surface-200-800\" />\n\n <!-- Details -->\n <div class=\"flex flex-col gap-3\">\n <div class=\"flex items-center justify-between\">\n <span class=\"text-surface-500 text-sm\">Email</span>\n <span class=\"text-surface-50-950 text-sm font-medium\">\n {data.user.email}\n </span>\n </div>\n\n {#if data.user.role}\n <div class=\"flex items-center justify-between\">\n <span class=\"text-surface-500 text-sm\">Role</span>\n <span\n class=\"inline-flex items-center rounded-full bg-primary-500/15 px-2.5 py-0.5 text-xs font-medium text-primary-700-300\"\n >\n {data.user.role}\n </span>\n </div>\n {/if}\n\n {#if data.user.name}\n <div class=\"flex items-center justify-between\">\n <span class=\"text-surface-500 text-sm\">Display Name</span>\n <span class=\"text-surface-50-950 text-sm font-medium\">\n {data.user.name}\n </span>\n </div>\n {/if}\n\n <div class=\"flex items-center justify-between\">\n <span class=\"text-surface-500 text-sm\">User ID</span>\n <span class=\"text-surface-50-950 text-xs font-mono\">\n {data.user.id.slice(0, 12)}…\n </span>\n </div>\n </div>\n </div>\n </div>\n</div>\n",
|
|
29
|
+
"/routes/(protected)/dashboard/account/+page.server.ts": "import { fail, superValidate } from 'sveltekit-superforms';\nimport { zod4 } from 'sveltekit-superforms/adapters';\nimport { accountSchema, changePasswordSchema } from '$lib/schemas';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ parent }) => {\n const { user } = await parent();\n\n const profileForm = await superValidate(\n { name: user.name ?? '', email: user.email },\n zod4(accountSchema)\n );\n const passwordForm = await superValidate(zod4(changePasswordSchema));\n\n return {\n profileForm,\n passwordForm\n };\n};\n\nexport const actions: Actions = {\n profile: async ({ request }) => {\n const form = await superValidate(request, zod4(accountSchema));\n if (!form.valid) return fail(400, { form });\n\n // TODO: Wire up better-auth updateProfile at install time\n // Example:\n // const result = await authClient.updateUser({ name: form.data.name, email: form.data.email });\n // if (result.error) return setError(form, '', result.error.message || 'Could not update profile');\n\n return { form };\n },\n\n password: async ({ request }) => {\n const form = await superValidate(request, zod4(changePasswordSchema));\n if (!form.valid) return fail(400, { form });\n\n // TODO: Wire up better-auth changePassword at install time\n // Example:\n // const result = await authClient.changePassword({\n // currentPassword: form.data.currentPassword,\n // newPassword: form.data.password\n // });\n // if (result.error) return setError(form, '', result.error.message || 'Could not change password');\n\n return { form };\n }\n};\n",
|
|
30
|
+
"/routes/(protected)/dashboard/account/+page.svelte": "<script lang=\"ts\">\n import type { PageData } from './$types';\n import { superForm } from 'sveltekit-superforms';\n import { zod4Client } from 'sveltekit-superforms/adapters';\n import { accountSchema, changePasswordSchema } from '$lib/schemas';\n import {\n Tabs,\n Card,\n Button,\n FormField,\n PasswordInput,\n addToast\n } from '$lib/components';\n\n let { data }: { data: PageData } = $props();\n\n // Profile form\n const {\n form: profileForm,\n errors: profileErrors,\n enhance: profileEnhance,\n submitting: profileSubmitting\n } = superForm(data.profileForm, {\n validators: zod4Client(accountSchema),\n dataType: 'json',\n invalidateAll: false,\n TTL: 0,\n onResult({ result }) {\n if (result.type === 'success') {\n addToast({ kind: 'success', title: 'Profile updated', description: 'Your profile has been saved.' });\n }\n }\n });\n\n // Password form\n const {\n form: passwordForm,\n errors: passwordErrors,\n enhance: passwordEnhance,\n submitting: passwordSubmitting\n } = superForm(data.passwordForm, {\n validators: zod4Client(changePasswordSchema),\n dataType: 'json',\n invalidateAll: false,\n TTL: 0,\n onResult({ result }) {\n if (result.type === 'success') {\n addToast({ kind: 'success', title: 'Password changed', description: 'Your password has been updated.' });\n }\n }\n });\n\n let activeTab = $state('profile');\n<\/script>\n\n{#snippet profileTab()}\n <Card variant=\"flat\" class=\"space-y-6\">\n <form method=\"POST\" action=\"?/profile\" use:profileEnhance class=\"space-y-4\">\n <FormField\n label=\"Name\"\n id=\"name\"\n type=\"text\"\n name=\"name\"\n placeholder=\"Your name\"\n bind:value={$profileForm.name}\n error={$profileErrors.name}\n />\n\n <FormField\n label=\"Email\"\n id=\"email\"\n type=\"email\"\n name=\"email\"\n placeholder=\"you@example.com\"\n required\n bind:value={$profileForm.email}\n error={$profileErrors.email}\n />\n\n <div class=\"flex justify-end pt-4 border-t border-surface-200-700\">\n <Button\n type=\"submit\"\n variant=\"primary\"\n loading={$profileSubmitting}\n loadingText=\"Saving…\"\n >\n Save Profile\n </Button>\n </div>\n </form>\n </Card>\n{/snippet}\n\n{#snippet passwordTab()}\n <Card variant=\"flat\" class=\"space-y-6\">\n <form method=\"POST\" action=\"?/password\" use:passwordEnhance class=\"space-y-4\">\n <PasswordInput\n id=\"currentPassword\"\n label=\"Current password\"\n name=\"currentPassword\"\n placeholder=\"••••••••\"\n required\n bind:value={$passwordForm.currentPassword}\n error={$passwordErrors.currentPassword}\n />\n\n <PasswordInput\n id=\"newPassword\"\n label=\"New password\"\n name=\"password\"\n placeholder=\"••••••••\"\n required\n showStrength\n bind:value={$passwordForm.password}\n error={$passwordErrors.password}\n />\n\n <PasswordInput\n id=\"confirmPassword\"\n label=\"Confirm new password\"\n name=\"confirmPassword\"\n placeholder=\"••••••••\"\n required\n bind:value={$passwordForm.confirmPassword}\n error={$passwordErrors.confirmPassword}\n />\n\n <div class=\"flex justify-end pt-4 border-t border-surface-200-700\">\n <Button\n type=\"submit\"\n variant=\"primary\"\n loading={$passwordSubmitting}\n loadingText=\"Changing…\"\n >\n Change Password\n </Button>\n </div>\n </form>\n </Card>\n{/snippet}\n\n<svelte:head>\n <title>Account — Dashboard — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <section class=\"flex flex-col gap-2\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">Account</h1>\n <p class=\"text-surface-500\">Manage your profile and security settings.</p>\n </section>\n\n <Tabs\n bind:value={activeTab}\n tabs={[\n { value: 'profile', label: 'Profile', content: profileTab },\n { value: 'password', label: 'Password', content: passwordTab }\n ]}\n variant=\"underline\"\n />\n</div>\n",
|
|
31
|
+
"/routes/(protected)/admin/notifications/page.test.ts": "/**\n * Admin Notifications Page Verification\n *\n * The notifications page is entirely client-side ($state, $derived). Component\n * rendering tests are too complex without a full Svelte mount. This test\n * verifies the page file exists and contains expected logic.\n */\nimport { describe, it, expect } from 'vitest';\nimport { readFileSync, existsSync } from 'fs';\nimport { resolve } from 'path';\n\nconst pagePath = resolve(import.meta.dirname, '+page.svelte');\n\ndescribe('admin/notifications page', () => {\n it('page file exists', () => {\n expect(existsSync(pagePath)).toBe(true);\n });\n\n it('imports notification store functions', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('getAdminNotifications');\n expect(content).toContain('createAdminNotification');\n expect(content).toContain('fetchNotifications');\n });\n\n it('uses $derived for adminNotifs', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('$derived');\n expect(content).toContain('adminNotifs');\n });\n\n it('exports handleCreate function', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('handleCreate');\n });\n\n it('has correct page title', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('Notifications — Admin — SvelteForge');\n });\n\n it('defines target options (all, admins, user)', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain(\"value: 'all'\");\n expect(content).toContain(\"value: 'admins'\");\n expect(content).toContain(\"value: 'user'\");\n });\n});\n",
|
|
32
|
+
"/routes/(protected)/admin/notifications/+page.svelte": "<script lang=\"ts\">\n import Card from '$lib/components/ui/Card.svelte';\n import DataTable from '$lib/components/ui/DataTable.svelte';\n import Button from '$lib/components/ui/Button.svelte';\n import Badge from '$lib/components/ui/Badge.svelte';\n import Modal from '$lib/components/ui/Modal.svelte';\n import EmptyState from '$lib/components/ui/EmptyState.svelte';\n import Input from '$lib/components/ui/form/Input.svelte';\n import TextArea from '$lib/components/ui/form/TextArea.svelte';\n import Select from '$lib/components/ui/form/Select.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n import { addToast } from '$lib/components/ui/toast-state.svelte';\n import {\n getAdminNotifications,\n createAdminNotification,\n fetchNotifications\n } from '$lib/stores/notification-store.svelte';\n\n // Initialize mock data\n fetchNotifications();\n\n let createOpen = $state(false);\n\n // Form state\n let formTitle = $state('');\n let formMessage = $state('');\n let formTarget = $state('all');\n\n // Admin notifications (reactive via getter)\n let adminNotifs = $derived(getAdminNotifications());\n\n let totalSent = $derived(adminNotifs.length);\n\n function formatDate(date: Date): string {\n return date.toLocaleDateString('en-US', {\n year: 'numeric',\n month: 'short',\n day: 'numeric'\n });\n }\n\n function openCreate() {\n formTitle = '';\n formMessage = '';\n formTarget = 'all';\n createOpen = true;\n }\n\n function handleCreate() {\n if (!formTitle.trim() || !formMessage.trim()) return;\n\n createAdminNotification({\n title: formTitle.trim(),\n message: formMessage.trim(),\n target: formTarget as 'all' | 'admins' | 'user'\n });\n\n createOpen = false;\n addToast({\n kind: 'success',\n title: 'Notification sent',\n description: `\"${formTitle.trim()}\" has been sent to ${formTarget === 'all' ? 'all users' : formTarget === 'admins' ? 'admins only' : 'the selected user'}.`\n });\n }\n\n const targetOptions = [\n { value: 'all', label: 'All Users' },\n { value: 'admins', label: 'Admins Only' },\n { value: 'user', label: 'Specific User' }\n ];\n<\/script>\n\n{#snippet targetCell(row: { target: string; [key: string]: unknown })}\n <Badge variant={row.target === 'all' ? 'primary' : row.target === 'admins' ? 'warning' : 'surface'}>\n {row.target === 'all' ? 'All Users' : row.target === 'admins' ? 'Admins' : 'User'}\n </Badge>\n{/snippet}\n\n{#snippet statusCell(row: { status: string; [key: string]: unknown })}\n <Badge variant={row.status === 'read' ? 'success' : 'surface'}>\n {row.status === 'read' ? 'Read' : 'Sent'}\n </Badge>\n{/snippet}\n\n{#snippet dateCell(row: { createdAt: Date; [key: string]: unknown })}\n <span class=\"text-surface-600-400 text-sm\">\n {formatDate(row.createdAt)}\n </span>\n{/snippet}\n\n<svelte:head>\n <title>Notifications — Admin — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <!-- Header -->\n <section class=\"flex flex-col gap-2\">\n <div class=\"flex items-center justify-between\">\n <div class=\"flex items-center gap-3\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">Notifications</h1>\n <span\n class=\"badge preset-tonal-surface-500\"\n style=\"font-size: var(--text-caption)\"\n >\n {totalSent} {totalSent === 1 ? 'notification' : 'notifications'}\n </span>\n </div>\n <Button variant=\"primary\" size=\"sm\" onclick={openCreate}>\n <Icon name=\"plus\" size={16} />\n Create Notification\n </Button>\n </div>\n <p class=\"text-surface-500\">Send and manage in-app notifications for your users.</p>\n </section>\n\n <!-- Content -->\n {#snippet createAction()}\n <Button variant=\"primary\" size=\"sm\" onclick={openCreate}>\n <Icon name=\"plus\" size={16} />\n Create Notification\n </Button>\n {/snippet}\n\n {#if adminNotifs.length === 0}\n <Card variant=\"flat\" noPadding>\n <EmptyState\n icon=\"bell\"\n title=\"No notifications yet\"\n description=\"Create your first notification to send to users.\"\n action={createAction}\n />\n </Card>\n {:else}\n <Card variant=\"flat\" noPadding>\n <DataTable\n columns={[\n { key: 'title', label: 'Title', sortable: true },\n { key: 'target', label: 'Target', cell: targetCell },\n { key: 'createdAt', label: 'Date', sortable: true, cell: dateCell },\n { key: 'status', label: 'Status', cell: statusCell }\n ]}\n data={adminNotifs as unknown as Record<string, unknown>[]}\n rowKey=\"id\"\n emptyMessage=\"No notifications sent yet.\"\n />\n </Card>\n {/if}\n</div>\n\n<!-- Create Modal -->\n<Modal open={createOpen} title=\"Create Notification\" onClose={() => (createOpen = false)} size=\"md\">\n {#snippet children()}\n <div class=\"flex flex-col gap-4\">\n <div>\n <label for=\"notif-title\" class=\"label\">\n <span class=\"label-text\">Title</span>\n <span class=\"text-error-500 ml-1\">*</span>\n </label>\n <Input\n id=\"notif-title\"\n name=\"title\"\n placeholder=\"Notification title\"\n bind:value={formTitle}\n required={true}\n />\n </div>\n <div>\n <label for=\"notif-message\" class=\"label\">\n <span class=\"label-text\">Message</span>\n <span class=\"text-error-500 ml-1\">*</span>\n </label>\n <TextArea\n id=\"notif-message\"\n name=\"message\"\n placeholder=\"Write your notification message...\"\n bind:value={formMessage}\n rows={4}\n required={true}\n />\n </div>\n <div>\n <Select\n id=\"notif-target\"\n name=\"target\"\n label=\"Target Audience\"\n bind:value={formTarget}\n options={targetOptions}\n required={true}\n />\n </div>\n </div>\n {/snippet}\n {#snippet footer()}\n <div class=\"flex items-center justify-end gap-3 px-4 py-3\">\n <Button variant=\"ghost\" size=\"sm\" onclick={() => (createOpen = false)}>\n Cancel\n </Button>\n <Button\n variant=\"primary\"\n size=\"sm\"\n onclick={handleCreate}\n disabled={!formTitle.trim() || !formMessage.trim()}\n >\n <Icon name=\"send\" size={16} />\n Send Notification\n </Button>\n </div>\n {/snippet}\n</Modal>\n",
|
|
33
|
+
"/routes/(protected)/admin/activity/+page.server.ts": "import type { PageServerLoad } from './$types';\n\ntype ActionType =\n | 'user.login'\n | 'user.logout'\n | 'user.create'\n | 'user.update'\n | 'user.delete'\n | 'role.change'\n | 'settings.update'\n | 'notification.send'\n | 'export.data'\n | 'api.key.rotate';\n\ninterface ActivityEntry {\n id: string;\n timestamp: string;\n user: string;\n action: ActionType;\n details: string;\n}\n\n// Mock data — replace with real DB/service layer when available\nconst mockActivity: ActivityEntry[] = [\n { id: 'act_01', timestamp: '2025-05-07T23:45:00Z', user: 'Alice Johnson', action: 'user.login', details: 'Logged in from 192.168.1.42 (Chrome/macOS)' },\n { id: 'act_02', timestamp: '2025-05-07T23:30:00Z', user: 'David Wilson', action: 'role.change', details: 'Changed Bob Smith\\'s role from \"user\" to \"admin\"' },\n { id: 'act_03', timestamp: '2025-05-07T23:15:00Z', user: 'Mia Harris', action: 'settings.update', details: 'Updated site notification preferences' },\n { id: 'act_04', timestamp: '2025-05-07T22:55:00Z', user: 'Henry Taylor', action: 'user.create', details: 'Created account for Oliver King (oliver@example.com)' },\n { id: 'act_05', timestamp: '2025-05-07T22:40:00Z', user: 'Alice Johnson', action: 'export.data', details: 'Exported user data as CSV (15 records)' },\n { id: 'act_06', timestamp: '2025-05-07T22:10:00Z', user: 'Bob Smith', action: 'user.login', details: 'Logged in from 10.0.0.15 (Firefox/Windows)' },\n { id: 'act_07', timestamp: '2025-05-07T21:50:00Z', user: 'David Wilson', action: 'user.update', details: 'Updated profile email for Eva Martinez' },\n { id: 'act_08', timestamp: '2025-05-07T21:30:00Z', user: 'Grace Lee', action: 'user.logout', details: 'Ended session after 2h 15m' },\n { id: 'act_09', timestamp: '2025-05-07T21:00:00Z', user: 'Alice Johnson', action: 'notification.send', details: 'Sent maintenance notification to 8 users' },\n { id: 'act_10', timestamp: '2025-05-07T20:45:00Z', user: 'Henry Taylor', action: 'api.key.rotate', details: 'Rotated API key for production environment' },\n { id: 'act_11', timestamp: '2025-05-07T20:20:00Z', user: 'Mia Harris', action: 'user.delete', details: 'Deleted spam account spammer99@example.com' },\n { id: 'act_12', timestamp: '2025-05-07T19:55:00Z', user: 'Carol Davis', action: 'user.login', details: 'Logged in from 172.16.0.8 (Safari/iOS)' },\n { id: 'act_13', timestamp: '2025-05-07T19:30:00Z', user: 'David Wilson', action: 'role.change', details: 'Changed Grace Lee\\'s role from \"user\" to \"admin\"' },\n { id: 'act_14', timestamp: '2025-05-07T19:00:00Z', user: 'Alice Johnson', action: 'settings.update', details: 'Updated default theme to dark mode' },\n { id: 'act_15', timestamp: '2025-05-07T18:30:00Z', user: 'Bob Smith', action: 'user.update', details: 'Changed own display name from \"Robert\" to \"Bob\"' },\n { id: 'act_16', timestamp: '2025-05-07T18:00:00Z', user: 'Henry Taylor', action: 'export.data', details: 'Exported activity log for audit review (PDF)' },\n { id: 'act_17', timestamp: '2025-05-07T17:30:00Z', user: 'Mia Harris', action: 'user.create', details: 'Created account for Chloe Adams (chloe@example.com)' },\n { id: 'act_18', timestamp: '2025-05-07T17:00:00Z', user: 'Alice Johnson', action: 'user.logout', details: 'Ended session after 4h 30m' },\n { id: 'act_19', timestamp: '2025-05-07T16:25:00Z', user: 'David Wilson', action: 'notification.send', details: 'Sent welcome email to 3 new users' },\n { id: 'act_20', timestamp: '2025-05-07T16:00:00Z', user: 'Grace Lee', action: 'user.login', details: 'Logged in from 192.168.2.10 (Edge/Windows)' },\n { id: 'act_21', timestamp: '2025-05-07T15:30:00Z', user: 'Henry Taylor', action: 'settings.update', details: 'Updated session timeout to 30 minutes' },\n { id: 'act_22', timestamp: '2025-05-07T15:00:00Z', user: 'Alice Johnson', action: 'api.key.rotate', details: 'Rotated API key for staging environment' },\n { id: 'act_23', timestamp: '2025-05-07T14:20:00Z', user: 'Mia Harris', action: 'user.update', details: 'Reset password for Jack Thomas' },\n { id: 'act_24', timestamp: '2025-05-07T13:45:00Z', user: 'Bob Smith', action: 'user.logout', details: 'Ended session after 1h 05m' },\n { id: 'act_25', timestamp: '2025-05-07T13:00:00Z', user: 'David Wilson', action: 'role.change', details: 'Changed Karen Jackson\\'s role from \"admin\" to \"user\"' },\n { id: 'act_26', timestamp: '2025-05-07T12:30:00Z', user: 'Alice Johnson', action: 'user.login', details: 'Logged in from 10.0.0.2 (Chrome/Linux)' },\n { id: 'act_27', timestamp: '2025-05-07T11:45:00Z', user: 'Henry Taylor', action: 'export.data', details: 'Exported monthly analytics report (JSON)' },\n { id: 'act_28', timestamp: '2025-05-07T11:00:00Z', user: 'Grace Lee', action: 'notification.send', details: 'Sent security alert to 2 users with expired passwords' },\n { id: 'act_29', timestamp: '2025-05-07T10:15:00Z', user: 'Mia Harris', action: 'user.create', details: 'Created account for Liam Chen (liam@example.com)' },\n { id: 'act_30', timestamp: '2025-05-07T09:30:00Z', user: 'Alice Johnson', action: 'settings.update', details: 'Enabled two-factor authentication requirement for admins' }\n];\n\nexport const load: PageServerLoad = async () => {\n return {\n activities: mockActivity\n };\n};\n",
|
|
34
|
+
"/routes/(protected)/admin/activity/+page.svelte": "<script lang=\"ts\">\n import type { PageData } from './$types';\n import DataTable from '$lib/components/ui/DataTable.svelte';\n import SearchInput from '$lib/components/ui/SearchInput.svelte';\n import Badge from '$lib/components/ui/Badge.svelte';\n import EmptyState from '$lib/components/ui/EmptyState.svelte';\n import Select from '$lib/components/ui/form/Select.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n type ActionType =\n | 'user.login'\n | 'user.logout'\n | 'user.create'\n | 'user.update'\n | 'user.delete'\n | 'role.change'\n | 'settings.update'\n | 'notification.send'\n | 'export.data'\n | 'api.key.rotate';\n\n type ActivityRow = { id: string; timestamp: string; user: string; action: ActionType; details: string; [key: string]: unknown };\n\n let { data }: { data: PageData } = $props();\n\n // --- State ---\n let search = $state('');\n let actionFilter = $state('');\n let page = $state(1);\n const perPage = 10;\n\n let activities = $state<ActivityRow[]>(data.activities as ActivityRow[]);\n\n // --- Derived ---\n let filtered = $derived(() => {\n let result = [...activities];\n\n if (actionFilter) {\n result = result.filter((a) => a.action === actionFilter);\n }\n\n if (search.trim()) {\n const q = search.toLowerCase().trim();\n result = result.filter(\n (a) =>\n a.user.toLowerCase().includes(q) ||\n a.action.toLowerCase().includes(q) ||\n a.details.toLowerCase().includes(q)\n );\n }\n\n return result;\n });\n\n let paged = $derived(() => {\n const all = filtered();\n const start = (page - 1) * perPage;\n return all.slice(start, start + perPage);\n });\n\n let totalPages = $derived(Math.max(1, Math.ceil(filtered().length / perPage)));\n\n $effect(() => {\n search;\n actionFilter;\n page = 1;\n });\n\n function formatTimestamp(iso: string): string {\n return new Date(iso).toLocaleString('en-US', {\n month: 'short',\n day: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n hour12: true\n });\n }\n\n function actionLabel(action: ActionType): string {\n const labels: Record<ActionType, string> = {\n 'user.login': 'Login',\n 'user.logout': 'Logout',\n 'user.create': 'User Created',\n 'user.update': 'User Updated',\n 'user.delete': 'User Deleted',\n 'role.change': 'Role Changed',\n 'settings.update': 'Settings Updated',\n 'notification.send': 'Notification Sent',\n 'export.data': 'Data Exported',\n 'api.key.rotate': 'API Key Rotated'\n };\n return labels[action] ?? action;\n }\n\n function actionBadgeVariant(action: ActionType): 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'surface' {\n const variants: Record<ActionType, 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'surface'> = {\n 'user.login': 'success',\n 'user.logout': 'surface',\n 'user.create': 'success',\n 'user.update': 'secondary',\n 'user.delete': 'error',\n 'role.change': 'warning',\n 'settings.update': 'secondary',\n 'notification.send': 'primary',\n 'export.data': 'primary',\n 'api.key.rotate': 'warning'\n };\n return variants[action] ?? 'surface';\n }\n\n const actionOptions = [\n { value: '', label: 'All Actions' },\n { value: 'user.login', label: 'Login' },\n { value: 'user.logout', label: 'Logout' },\n { value: 'user.create', label: 'User Created' },\n { value: 'user.update', label: 'User Updated' },\n { value: 'user.delete', label: 'User Deleted' },\n { value: 'role.change', label: 'Role Changed' },\n { value: 'settings.update', label: 'Settings Updated' },\n { value: 'notification.send', label: 'Notification Sent' },\n { value: 'export.data', label: 'Data Exported' },\n { value: 'api.key.rotate', label: 'API Key Rotated' }\n ];\n<\/script>\n\n{#snippet timestampCell(row: ActivityRow)}\n <span class=\"text-surface-600-400 text-sm whitespace-nowrap\">\n {formatTimestamp(row.timestamp)}\n </span>\n{/snippet}\n\n{#snippet userCell(row: ActivityRow)}\n <span class=\"font-medium text-surface-50-950\">\n {row.user}\n </span>\n{/snippet}\n\n{#snippet actionCell(row: ActivityRow)}\n <Badge variant={actionBadgeVariant(row.action)}>\n {actionLabel(row.action)}\n </Badge>\n{/snippet}\n\n{#snippet detailsCell(row: ActivityRow)}\n <span class=\"text-surface-600-400 text-sm\">\n {row.details}\n </span>\n{/snippet}\n\n<svelte:head>\n <title>Activity Log — Admin — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <!-- Header -->\n <section class=\"flex flex-col gap-2\">\n <div class=\"flex items-center gap-3\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">Activity Log</h1>\n <span\n class=\"badge preset-tonal-surface-500\"\n style=\"font-size: var(--text-caption)\"\n >\n {filtered().length} {filtered().length === 1 ? 'entry' : 'entries'}\n </span>\n </div>\n <p class=\"text-surface-500\">Audit trail of all administrative actions and system events.</p>\n </section>\n\n <!-- Filters Bar -->\n <section class=\"flex flex-col sm:flex-row gap-3\">\n <div class=\"flex-1\">\n <SearchInput bind:value={search} placeholder=\"Search by user, action, or details...\" name=\"activity-search\" />\n </div>\n <div class=\"w-full sm:w-52\">\n <Select\n id=\"action-filter\"\n name=\"action-filter\"\n bind:value={actionFilter}\n options={actionOptions}\n />\n </div>\n </section>\n\n <!-- Content -->\n {#if filtered().length === 0}\n <div class=\"card bg-surface-50-800 border border-surface-200-700\" style=\"border-radius: var(--radius-card)\">\n <EmptyState\n icon=\"clock\"\n title=\"No activity found\"\n description=\"Try adjusting your search or filter criteria.\"\n />\n </div>\n {:else}\n <div class=\"card bg-surface-50-800 border border-surface-200-700 overflow-hidden\" style=\"border-radius: var(--radius-card)\">\n <DataTable\n columns={[\n { key: 'timestamp', label: 'Timestamp', sortable: true, cell: timestampCell },\n { key: 'user', label: 'User', sortable: true, cell: userCell },\n { key: 'action', label: 'Action', sortable: true, cell: actionCell },\n { key: 'details', label: 'Details', cell: detailsCell }\n ]}\n data={paged()}\n rowKey=\"id\"\n emptyMessage=\"No activity matches your filters.\"\n />\n </div>\n\n <!-- Pagination -->\n {#if totalPages > 1}\n <div class=\"flex items-center justify-between\">\n <p class=\"text-sm text-surface-500\">\n Page {page} of {totalPages} — {filtered().length} result{filtered().length !== 1 ? 's' : ''}\n </p>\n <div class=\"flex items-center gap-2\">\n <button\n class=\"btn preset-outlined-secondary-500\"\n disabled={page <= 1}\n onclick={() => (page -= 1)}\n >\n <Icon name=\"chevronLeft\" size={16} />\n Prev\n </button>\n <button\n class=\"btn preset-outlined-secondary-500\"\n disabled={page >= totalPages}\n onclick={() => (page += 1)}\n >\n Next\n <Icon name=\"chevronRight\" size={16} />\n </button>\n </div>\n </div>\n {/if}\n {/if}\n</div>\n",
|
|
35
|
+
"/routes/(protected)/admin/+layout.svelte": "<script lang=\"ts\">\n import AdminSidebar from '$lib/components/layout/AdminSidebar.svelte';\n import Breadcrumb from '$lib/components/ui/Breadcrumb.svelte';\n import { page } from '$app/state';\n import type { LayoutData } from './$types';\n\n let { data, children }: { data: LayoutData; children: any } = $props();\n\n const breadcrumbItems = $derived.by(() => {\n const segments = page.url.pathname\n .split('/')\n .filter(Boolean);\n\n // Find the index of \"admin\" in the segments\n const adminIndex = segments.indexOf('admin');\n if (adminIndex === -1) return [];\n\n // Only include segments from \"admin\" onward\n const adminSegments = segments.slice(adminIndex);\n\n return adminSegments.map((segment, i) => {\n const label = segment.charAt(0).toUpperCase() + segment.slice(1);\n if (i === 0) {\n // First segment always links to /admin\n return { label, href: '/admin' };\n }\n // Last segment has no href (current page)\n if (i === adminSegments.length - 1) {\n return { label };\n }\n // Middle segments link to their accumulated path\n const href = '/' + segments.slice(0, adminIndex + i + 1).join('/');\n return { label, href };\n });\n });\n<\/script>\n\n<div class=\"flex min-h-screen bg-surface-50-950\">\n <AdminSidebar user={data.user} />\n <main class=\"flex-1 overflow-y-auto p-6 lg:p-8\">\n {#if breadcrumbItems.length > 0}\n <div class=\"hidden md:block mb-4\">\n <Breadcrumb items={breadcrumbItems} />\n </div>\n {/if}\n {@render children()}\n </main>\n</div>\n",
|
|
36
|
+
"/routes/(protected)/admin/layout.server.test.ts": "/**\n * Admin Layout Server Guard Tests\n *\n * Tests that the admin layout load function correctly redirects\n * non-admin users and allows admin users through.\n */\nimport { describe, it, expect, vi } from 'vitest';\n\n// Mock @sveltejs/kit redirect before importing the module\nvi.mock('@sveltejs/kit', () => ({\n redirect: (status: number, location: string) => {\n const err = new Error(`Redirect ${status} → ${location}`);\n (err as any).status = status;\n (err as any).location = location;\n throw err;\n }\n}));\n\n// Dynamic import to avoid + prefix issues with module resolution\nconst loadModule = await import('./+layout.server');\n\n// Helper to build a fake load context\nfunction makeContext(user: any) {\n return {\n parent: vi.fn().mockResolvedValue({ user })\n };\n}\n\ndescribe('admin +layout.server.ts', () => {\n it('redirects to /login when no user', async () => {\n const ctx = makeContext(null);\n await expect(loadModule.load(ctx as any)).rejects.toThrow('Redirect 303 → /login');\n });\n\n it('redirects to /dashboard when user role is not admin', async () => {\n const ctx = makeContext({ id: '1', name: 'User', role: 'user' });\n await expect(loadModule.load(ctx as any)).rejects.toThrow('Redirect 303 → /dashboard');\n });\n\n it('allows admin user and returns user data', async () => {\n const adminUser = { id: '1', name: 'Admin', role: 'admin' };\n const ctx = makeContext(adminUser);\n const result = await loadModule.load(ctx as any);\n expect(result).toEqual({ user: adminUser });\n });\n});\n",
|
|
37
|
+
"/routes/(protected)/admin/+page.svelte": "<script lang=\"ts\">\n import type { PageData } from './$types';\n import Card from '$lib/components/ui/Card.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n let { data }: { data: PageData } = $props();\n<\/script>\n\n<svelte:head>\n <title>Admin Dashboard — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <!-- Header -->\n <section class=\"flex flex-col gap-2\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">Admin Dashboard</h1>\n <p class=\"text-surface-500\">Overview of your application.</p>\n </section>\n\n <!-- Stat Cards -->\n <div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6\">\n <Card title=\"Users\" variant=\"flat\">\n <div class=\"flex items-center gap-4\">\n <div class=\"flex items-center justify-center rounded-lg bg-primary-500/10 text-primary-500 p-3\">\n <Icon name=\"users\" size={24} />\n </div>\n <div class=\"flex flex-col\">\n <span class=\"text-2xl font-bold text-surface-50-950\">—</span>\n <span class=\"text-sm text-surface-500\">Total users</span>\n </div>\n </div>\n </Card>\n\n <Card title=\"Active Sessions\" variant=\"flat\">\n <div class=\"flex items-center gap-4\">\n <div class=\"flex items-center justify-center rounded-lg bg-success-500/10 text-success-500 p-3\">\n <Icon name=\"shieldCheck\" size={24} />\n </div>\n <div class=\"flex flex-col\">\n <span class=\"text-2xl font-bold text-surface-50-950\">—</span>\n <span class=\"text-sm text-surface-500\">Currently online</span>\n </div>\n </div>\n </Card>\n\n <Card title=\"System\" variant=\"flat\">\n <div class=\"flex items-center gap-4\">\n <div class=\"flex items-center justify-center rounded-lg bg-secondary-500/10 text-secondary-500 p-3\">\n <Icon name=\"settings\" size={24} />\n </div>\n <div class=\"flex flex-col\">\n <span class=\"text-2xl font-bold text-surface-50-950\">OK</span>\n <span class=\"text-sm text-surface-500\">All systems operational</span>\n </div>\n </div>\n </Card>\n </div>\n</div>\n",
|
|
38
|
+
"/routes/(protected)/admin/+layout.server.ts": "import { redirect } from '@sveltejs/kit';\nimport type { LayoutServerLoad } from './$types';\n\nexport const load: LayoutServerLoad = async ({ parent }) => {\n const { user } = await parent();\n\n if (!user) {\n throw redirect(303, '/login');\n }\n\n if (user.role !== 'admin') {\n throw redirect(303, '/dashboard');\n }\n\n return {\n user\n };\n};\n",
|
|
39
|
+
"/routes/(protected)/admin/settings/page.test.ts": "/**\n * Admin Settings Page Verification\n *\n * The settings page is entirely client-side ($state). Component rendering\n * tests are too complex without a full Svelte mount. This test verifies the\n * page file exists and is a valid Svelte module.\n */\nimport { describe, it, expect } from 'vitest';\nimport { readFileSync, existsSync } from 'fs';\nimport { resolve } from 'path';\n\nconst pagePath = resolve(import.meta.dirname, '+page.svelte');\n\ndescribe('admin/settings page', () => {\n it('page file exists', () => {\n expect(existsSync(pagePath)).toBe(true);\n });\n\n it('contains settings state definition', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('settings');\n expect(content).toContain('$state');\n });\n\n it('contains expected setting keys', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('appName');\n expect(content).toContain('allowRegistration');\n expect(content).toContain('defaultRole');\n });\n\n it('exports a handleSave function', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('handleSave');\n });\n\n it('has correct page title', () => {\n const content = readFileSync(pagePath, 'utf-8');\n expect(content).toContain('Settings — Admin — SvelteForge');\n });\n});\n",
|
|
40
|
+
"/routes/(protected)/admin/settings/+page.svelte": "<script lang=\"ts\">\n import type { PageData } from './$types';\n import Tabs from '$lib/components/ui/Tabs.svelte';\n import Card from '$lib/components/ui/Card.svelte';\n import Button from '$lib/components/ui/Button.svelte';\n import Switch from '$lib/components/ui/Switch.svelte';\n import Input from '$lib/components/ui/form/Input.svelte';\n import Select from '$lib/components/ui/form/Select.svelte';\n import { addToast } from '$lib/components/ui/toast-state.svelte';\n\n let { data }: { data: PageData } = $props();\n\n // Reactive settings state with sensible defaults\n let settings = $state({\n appName: 'SvelteForge',\n appDescription: 'A full-stack SvelteKit application template',\n logoUrl: '',\n allowRegistration: true,\n defaultRole: 'user',\n requireEmailVerification: false,\n enableEmailNotifications: false,\n notificationEmail: 'admin@example.com',\n sendOnNewUserSignup: true\n });\n\n let activeTab = $state('general');\n\n function handleSave() {\n // Client-side only — no server persistence\n addToast({ kind: 'success', title: 'Settings saved' });\n }\n<\/script>\n\n{#snippet generalTab()}\n <Card variant=\"flat\" class=\"space-y-6\">\n <div class=\"space-y-4\">\n <div class=\"space-y-1\">\n <label for=\"app-name\" class=\"label\">\n <span class=\"label-text\">App Name</span>\n </label>\n <p class=\"text-surface-500\" style=\"font-size: var(--text-caption)\">\n The display name of your application.\n </p>\n <Input id=\"app-name\" bind:value={settings.appName} placeholder=\"My App\" />\n </div>\n\n <div class=\"space-y-1\">\n <label for=\"app-description\" class=\"label\">\n <span class=\"label-text\">App Description</span>\n </label>\n <p class=\"text-surface-500\" style=\"font-size: var(--text-caption)\">\n A short description used in meta tags and the homepage.\n </p>\n <Input\n id=\"app-description\"\n bind:value={settings.appDescription}\n placeholder=\"A brief description of your app\"\n />\n </div>\n\n <div class=\"space-y-1\">\n <label for=\"logo-url\" class=\"label\">\n <span class=\"label-text\">Logo URL</span>\n </label>\n <p class=\"text-surface-500\" style=\"font-size: var(--text-caption)\">\n URL to your app logo image. Leave empty for the default.\n </p>\n <Input\n id=\"logo-url\"\n bind:value={settings.logoUrl}\n placeholder=\"https://example.com/logo.png\"\n />\n </div>\n </div>\n\n <div class=\"flex justify-end pt-4 border-t border-surface-200-700\">\n <Button variant=\"primary\" onclick={handleSave}>Save Changes</Button>\n </div>\n </Card>\n{/snippet}\n\n{#snippet authTab()}\n <Card variant=\"flat\" class=\"space-y-6\">\n <div class=\"space-y-5\">\n <Switch\n checked={settings.allowRegistration}\n onCheckedChange={(val) => (settings.allowRegistration = val)}\n label=\"Allow Registration\"\n description=\"Enable new users to create accounts on their own.\"\n />\n\n <div class=\"space-y-1\">\n <Select\n id=\"default-role\"\n bind:value={settings.defaultRole}\n label=\"Default Role for New Users\"\n options={[\n { value: 'user', label: 'User' },\n { value: 'admin', label: 'Admin' }\n ]}\n />\n <p class=\"text-surface-500\" style=\"font-size: var(--text-caption)\">\n Newly registered users will be assigned this role automatically.\n </p>\n </div>\n\n <Switch\n checked={settings.requireEmailVerification}\n onCheckedChange={(val) => (settings.requireEmailVerification = val)}\n label=\"Require Email Verification\"\n description=\"Users must verify their email before accessing the platform.\"\n />\n </div>\n\n <div class=\"flex justify-end pt-4 border-t border-surface-200-700\">\n <Button variant=\"primary\" onclick={handleSave}>Save Changes</Button>\n </div>\n </Card>\n{/snippet}\n\n{#snippet notificationsTab()}\n <Card variant=\"flat\" class=\"space-y-6\">\n <div class=\"space-y-5\">\n <Switch\n checked={settings.enableEmailNotifications}\n onCheckedChange={(val) => (settings.enableEmailNotifications = val)}\n label=\"Enable Email Notifications\"\n description=\"Allow the system to send email notifications.\"\n />\n\n <div class=\"space-y-1\">\n <label for=\"notification-email\" class=\"label\">\n <span class=\"label-text\">Notification Email Address</span>\n </label>\n <p class=\"text-surface-500\" style=\"font-size: var(--text-caption)\">\n Email address used as the sender for system notifications.\n </p>\n <Input\n id=\"notification-email\"\n type=\"email\"\n bind:value={settings.notificationEmail}\n placeholder=\"noreply@example.com\"\n />\n </div>\n\n <Switch\n checked={settings.sendOnNewUserSignup}\n onCheckedChange={(val) => (settings.sendOnNewUserSignup = val)}\n label=\"Send on New User Signup\"\n description=\"Notify admins when a new user registers.\"\n />\n </div>\n\n <div class=\"flex justify-end pt-4 border-t border-surface-200-700\">\n <Button variant=\"primary\" onclick={handleSave}>Save Changes</Button>\n </div>\n </Card>\n{/snippet}\n\n<svelte:head>\n <title>Settings — Admin — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <section class=\"flex flex-col gap-2\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">Settings</h1>\n <p class=\"text-surface-500\">Configure your application settings.</p>\n </section>\n\n <Tabs\n bind:value={activeTab}\n tabs={[\n { value: 'general', label: 'General', content: generalTab },\n { value: 'auth', label: 'Authentication', content: authTab },\n { value: 'notifications', label: 'Notifications', content: notificationsTab }\n ]}\n variant=\"underline\"\n />\n</div>\n",
|
|
41
|
+
"/routes/(protected)/admin/users/+page.server.ts": "import type { PageServerLoad } from './$types';\n\ninterface MockUser {\n id: string;\n name: string;\n email: string;\n role: 'admin' | 'user';\n createdAt: string;\n}\n\n// Mock data — replace with real DB/service layer when available\nconst mockUsers: MockUser[] = [\n { id: 'usr_01', name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', createdAt: '2024-11-02T08:30:00Z' },\n { id: 'usr_02', name: 'Bob Smith', email: 'bob@example.com', role: 'user', createdAt: '2024-11-05T14:12:00Z' },\n { id: 'usr_03', name: 'Carol Davis', email: 'carol@example.com', role: 'user', createdAt: '2024-11-08T09:45:00Z' },\n { id: 'usr_04', name: 'David Wilson', email: 'david@example.com', role: 'admin', createdAt: '2024-11-12T16:20:00Z' },\n { id: 'usr_05', name: 'Eva Martinez', email: 'eva@example.com', role: 'user', createdAt: '2024-11-15T11:05:00Z' },\n { id: 'usr_06', name: 'Frank Brown', email: 'frank@example.com', role: 'user', createdAt: '2024-11-18T13:30:00Z' },\n { id: 'usr_07', name: 'Grace Lee', email: 'grace@example.com', role: 'user', createdAt: '2024-12-01T10:00:00Z' },\n { id: 'usr_08', name: 'Henry Taylor', email: 'henry@example.com', role: 'admin', createdAt: '2024-12-03T15:45:00Z' },\n { id: 'usr_09', name: 'Iris Anderson', email: 'iris@example.com', role: 'user', createdAt: '2024-12-07T08:15:00Z' },\n { id: 'usr_10', name: 'Jack Thomas', email: 'jack@example.com', role: 'user', createdAt: '2024-12-10T12:00:00Z' },\n { id: 'usr_11', name: 'Karen Jackson', email: 'karen@example.com', role: 'user', createdAt: '2025-01-02T09:30:00Z' },\n { id: 'usr_12', name: 'Leo White', email: 'leo@example.com', role: 'user', createdAt: '2025-01-05T14:00:00Z' },\n { id: 'usr_13', name: 'Mia Harris', email: 'mia@example.com', role: 'admin', createdAt: '2025-01-08T10:30:00Z' },\n { id: 'usr_14', name: 'Noah Clark', email: 'noah@example.com', role: 'user', createdAt: '2025-01-12T16:45:00Z' },\n { id: 'usr_15', name: 'Olivia Lewis', email: 'olivia@example.com', role: 'user', createdAt: '2025-01-15T11:20:00Z' }\n];\n\nexport const load: PageServerLoad = async () => {\n return {\n users: mockUsers\n };\n};\n",
|
|
42
|
+
"/routes/(protected)/admin/users/+page.svelte": "<script lang=\"ts\">\n import type { PageData } from './$types';\n import DataTable from '$lib/components/ui/DataTable.svelte';\n import SearchInput from '$lib/components/ui/SearchInput.svelte';\n import Avatar from '$lib/components/ui/Avatar.svelte';\n import Badge from '$lib/components/ui/Badge.svelte';\n import ConfirmDialog from '$lib/components/ui/ConfirmDialog.svelte';\n import EmptyState from '$lib/components/ui/EmptyState.svelte';\n import Select from '$lib/components/ui/form/Select.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n import { addToast } from '$lib/components/ui/toast-state.svelte';\n import { exportToCSV, type ExportColumn } from '$lib/utils/export';\n\n type UserRow = { id: string; name: string; email: string; role: 'admin' | 'user'; createdAt: string; [key: string]: unknown };\n\n let { data }: { data: PageData } = $props();\n\n // --- State ---\n let search = $state('');\n let roleFilter = $state('');\n let page = $state(1);\n const perPage = 20;\n\n let confirmOpen = $state(false);\n let confirmAction = $state<'role' | 'delete' | 'bulkRole' | 'bulkDelete' | null>(null);\n let selectedUser = $state<UserRow | null>(null);\n\n let users = $state<UserRow[]>(data.users as UserRow[]);\n\n // --- Bulk selection state ---\n let selectedIds = $state<Set<string>>(new Set());\n let bulkRole = $state<'admin' | 'user'>('user');\n\n // --- Derived ---\n let filtered = $derived(() => {\n let result = [...users];\n\n if (roleFilter) {\n result = result.filter((u) => u.role === roleFilter);\n }\n\n if (search.trim()) {\n const q = search.toLowerCase().trim();\n result = result.filter(\n (u) => u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)\n );\n }\n\n return result;\n });\n\n let paged = $derived(() => {\n const all = filtered();\n const start = (page - 1) * perPage;\n return all.slice(start, start + perPage);\n });\n\n let totalPages = $derived(Math.max(1, Math.ceil(filtered().length / perPage)));\n\n let allOnPageSelected = $derived(() => {\n const rows = paged();\n return rows.length > 0 && rows.every((u) => selectedIds.has(u.id));\n });\n\n let selectedCount = $derived(selectedIds.size);\n\n $effect(() => {\n search;\n roleFilter;\n page = 1;\n });\n\n // Clear selections when filters change\n $effect(() => {\n search;\n roleFilter;\n selectedIds = new Set();\n });\n\n function formatDate(iso: string): string {\n return new Date(iso).toLocaleDateString('en-US', {\n year: 'numeric',\n month: 'short',\n day: 'numeric'\n });\n }\n\n // --- Selection helpers ---\n function toggleRow(id: string) {\n const next = new Set(selectedIds);\n if (next.has(id)) {\n next.delete(id);\n } else {\n next.add(id);\n }\n selectedIds = next;\n }\n\n function toggleAllOnPage() {\n const rows = paged();\n if (allOnPageSelected()) {\n // Deselect all on current page\n const next = new Set(selectedIds);\n for (const u of rows) next.delete(u.id);\n selectedIds = next;\n } else {\n // Select all on current page\n const next = new Set(selectedIds);\n for (const u of rows) next.add(u.id);\n selectedIds = next;\n }\n }\n\n function clearSelection() {\n selectedIds = new Set();\n }\n\n // --- Single-user confirm ---\n function openConfirm(user: UserRow, action: 'role' | 'delete') {\n selectedUser = user;\n confirmAction = action;\n confirmOpen = true;\n }\n\n // --- Bulk confirm ---\n function openBulkConfirm(action: 'bulkRole' | 'bulkDelete') {\n confirmAction = action;\n confirmOpen = true;\n }\n\n function handleConfirm() {\n if (confirmAction === 'role' && selectedUser) {\n const newRole = selectedUser.role === 'admin' ? 'user' : 'admin';\n users = users.map((u) =>\n u.id === selectedUser!.id ? { ...u, role: newRole } : u\n );\n addToast({\n kind: 'success',\n title: 'Role updated',\n description: `${selectedUser.name} is now ${newRole === 'admin' ? 'an admin' : 'a user'}.`\n });\n } else if (confirmAction === 'delete' && selectedUser) {\n addToast({\n kind: 'info',\n title: 'Feature coming in API integration',\n description: 'User deletion will be available once the API is connected.'\n });\n } else if (confirmAction === 'bulkRole') {\n let count = 0;\n users = users.map((u) => {\n if (selectedIds.has(u.id) && u.role !== bulkRole) {\n count++;\n return { ...u, role: bulkRole };\n }\n return u;\n });\n addToast({\n kind: 'success',\n title: 'Bulk role update',\n description: `${count} user${count !== 1 ? 's' : ''} updated to \"${bulkRole}\".`\n });\n } else if (confirmAction === 'bulkDelete') {\n const count = selectedIds.size;\n addToast({\n kind: 'info',\n title: 'Feature coming in API integration',\n description: `Bulk deletion of ${count} user${count !== 1 ? 's' : ''} will be available once the API is connected.`\n });\n }\n\n confirmOpen = false;\n selectedUser = null;\n confirmAction = null;\n selectedIds = new Set();\n }\n\n function handleCancel() {\n confirmOpen = false;\n selectedUser = null;\n confirmAction = null;\n }\n\n // --- CSV export ---\n const exportColumns: ExportColumn<UserRow>[] = [\n { key: 'name', label: 'Name' },\n { key: 'email', label: 'Email' },\n { key: 'role', label: 'Role' },\n {\n key: 'createdAt',\n label: 'Created',\n format: (_v, row) => formatDate(row.createdAt)\n }\n ];\n\n function handleExportCSV() {\n const dataToExport = selectedCount > 0\n ? filtered().filter((u) => selectedIds.has(u.id))\n : filtered();\n if (dataToExport.length === 0) {\n addToast({ kind: 'warning', title: 'No data to export', description: 'Adjust your filters or selection.' });\n return;\n }\n const timestamp = new Date().toISOString().slice(0, 10);\n exportToCSV(dataToExport, `users-export-${timestamp}`, exportColumns);\n addToast({\n kind: 'success',\n title: 'CSV exported',\n description: `Exported ${dataToExport.length} user${dataToExport.length !== 1 ? 's' : ''}.`\n });\n }\n\n const roleOptions = [\n { value: '', label: 'All Roles' },\n { value: 'admin', label: 'Admin' },\n { value: 'user', label: 'User' }\n ];\n<\/script>\n\n{#snippet checkboxHeaderCell()}\n <input\n type=\"checkbox\"\n class=\"checkbox size-4\"\n checked={allOnPageSelected()}\n indeterminate={selectedCount > 0 && !allOnPageSelected()}\n onchange={toggleAllOnPage}\n aria-label=\"Select all users on this page\"\n />\n{/snippet}\n\n{#snippet checkboxCell(row: UserRow)}\n <input\n type=\"checkbox\"\n class=\"checkbox size-4\"\n checked={selectedIds.has(row.id)}\n onchange={() => toggleRow(row.id)}\n aria-label=\"Select {row.name}\"\n />\n{/snippet}\n\n{#snippet avatarCell(row: UserRow)}\n <Avatar alt={row.name} size=\"sm\" />\n{/snippet}\n\n{#snippet roleCell(row: UserRow)}\n <Badge variant={row.role === 'admin' ? 'primary' : 'surface'}>\n {row.role === 'admin' ? 'Admin' : 'User'}\n </Badge>\n{/snippet}\n\n{#snippet dateCell(row: UserRow)}\n <span class=\"text-surface-600-400 text-sm\">\n {formatDate(row.createdAt)}\n </span>\n{/snippet}\n\n{#snippet actionsCell(row: UserRow)}\n <div class=\"flex items-center justify-end gap-2\">\n <button\n class=\"btn preset-tonal-primary-500 text-xs\"\n onclick={() => openConfirm(row, 'role')}\n >\n <Icon name=\"shield\" size={14} />\n Change Role\n </button>\n <button\n class=\"btn preset-tonal-error-500 text-xs\"\n onclick={() => openConfirm(row, 'delete')}\n >\n <Icon name=\"trash\" size={14} />\n Delete\n </button>\n </div>\n{/snippet}\n\n<svelte:head>\n <title>User Management — Admin — SvelteForge</title>\n</svelte:head>\n\n<div class=\"flex flex-col gap-8\">\n <!-- Header -->\n <section class=\"flex flex-col gap-2\">\n <div class=\"flex items-center gap-3\">\n <h1 class=\"text-3xl font-bold text-surface-50-950\">User Management</h1>\n <span\n class=\"badge preset-tonal-surface-500\"\n style=\"font-size: var(--text-caption)\"\n >\n {filtered().length} {filtered().length === 1 ? 'user' : 'users'}\n </span>\n </div>\n <p class=\"text-surface-500\">Manage your application users, roles, and permissions.</p>\n </section>\n\n <!-- Filters Bar -->\n <section class=\"flex flex-col sm:flex-row gap-3\">\n <div class=\"flex-1\">\n <SearchInput bind:value={search} placeholder=\"Search by name or email...\" name=\"user-search\" />\n </div>\n <div class=\"w-full sm:w-48\">\n <Select\n id=\"role-filter\"\n name=\"role-filter\"\n bind:value={roleFilter}\n options={roleOptions}\n />\n </div>\n <button\n class=\"btn preset-outlined-secondary-500 whitespace-nowrap\"\n onclick={handleExportCSV}\n >\n <Icon name=\"fileText\" size={16} />\n Export CSV\n </button>\n </section>\n\n <!-- Bulk Action Bar -->\n {#if selectedCount > 0}\n <section class=\"flex flex-col sm:flex-row items-start sm:items-center gap-3 p-3 rounded-lg bg-surface-100-800 border border-surface-200-700\">\n <span class=\"text-sm text-surface-600-400 font-medium\">\n {selectedCount} user{selectedCount !== 1 ? 's' : ''} selected\n </span>\n <div class=\"flex items-center gap-2 flex-wrap\">\n <div class=\"flex items-center gap-2\">\n <select\n class=\"select-input text-sm py-1\"\n bind:value={bulkRole}\n aria-label=\"Bulk role selection\"\n >\n <option value=\"user\">User</option>\n <option value=\"admin\">Admin</option>\n </select>\n <button\n class=\"btn preset-tonal-primary-500 text-xs\"\n onclick={() => openBulkConfirm('bulkRole')}\n >\n <Icon name=\"shield\" size={14} />\n Change Role\n </button>\n </div>\n <button\n class=\"btn preset-tonal-error-500 text-xs\"\n onclick={() => openBulkConfirm('bulkDelete')}\n >\n <Icon name=\"trash\" size={14} />\n Delete\n </button>\n <button\n class=\"btn preset-outlined-surface-500 text-xs ml-auto\"\n onclick={clearSelection}\n >\n <Icon name=\"x\" size={14} />\n Clear\n </button>\n </div>\n </section>\n {/if}\n\n <!-- Content -->\n {#if filtered().length === 0}\n <div class=\"card bg-surface-50-800 border border-surface-200-700\" style=\"border-radius: var(--radius-card)\">\n <EmptyState\n icon=\"users\"\n title=\"No users found\"\n description=\"Try adjusting your search or filter criteria.\"\n />\n </div>\n {:else}\n <div class=\"card bg-surface-50-800 border border-surface-200-700 overflow-hidden\" style=\"border-radius: var(--radius-card)\">\n <DataTable\n columns={[\n { key: '_checkbox', label: '', width: 'w-12', cell: checkboxCell, headerCell: checkboxHeaderCell },\n { key: 'avatar', label: '', width: 'w-12', cell: avatarCell },\n { key: 'name', label: 'Name', sortable: true },\n { key: 'email', label: 'Email', sortable: true },\n { key: 'role', label: 'Role', sortable: true, cell: roleCell },\n { key: 'createdAt', label: 'Created', sortable: true, cell: dateCell },\n { key: 'actions', label: 'Actions', align: 'right' as const, cell: actionsCell }\n ]}\n data={paged()}\n rowKey=\"id\"\n emptyMessage=\"No users match your filters.\"\n />\n </div>\n\n <!-- Pagination -->\n {#if totalPages > 1}\n <div class=\"flex items-center justify-between\">\n <p class=\"text-sm text-surface-500\">\n Page {page} of {totalPages} — {filtered().length} result{filtered().length !== 1 ? 's' : ''}\n </p>\n <div class=\"flex items-center gap-2\">\n <button\n class=\"btn preset-outlined-secondary-500\"\n disabled={page <= 1}\n onclick={() => (page -= 1)}\n >\n <Icon name=\"chevronLeft\" size={16} />\n Prev\n </button>\n <button\n class=\"btn preset-outlined-secondary-500\"\n disabled={page >= totalPages}\n onclick={() => (page += 1)}\n >\n Next\n <Icon name=\"chevronRight\" size={16} />\n </button>\n </div>\n </div>\n {/if}\n {/if}\n</div>\n\n<!-- Confirm Dialog -->\n{#if confirmOpen}\n <ConfirmDialog\n bind:open={confirmOpen}\n title={\n confirmAction === 'bulkDelete' ? `Delete ${selectedCount} User${selectedCount !== 1 ? 's' : ''}?`\n : confirmAction === 'bulkRole' ? `Change Role for ${selectedCount} User${selectedCount !== 1 ? 's' : ''}?`\n : confirmAction === 'role' && selectedUser ? 'Change User Role'\n : 'Delete User'\n }\n message={\n confirmAction === 'bulkDelete' ? `Are you sure you want to delete ${selectedCount} user${selectedCount !== 1 ? 's' : ''}? This action cannot be undone.`\n : confirmAction === 'bulkRole' ? `Are you sure you want to change the role to \"${bulkRole}\" for ${selectedCount} user${selectedCount !== 1 ? 's' : ''}?`\n : confirmAction === 'role' && selectedUser ? `Are you sure you want to change ${selectedUser.name}'s role from \"${selectedUser.role}\" to \"${selectedUser.role === 'admin' ? 'user' : 'admin'}\"?`\n : selectedUser ? `Are you sure you want to delete ${selectedUser.name}? This action cannot be undone.`\n : ''\n }\n confirmLabel={\n confirmAction === 'bulkDelete' ? `Delete ${selectedCount} User${selectedCount !== 1 ? 's' : ''}`\n : confirmAction === 'bulkRole' ? 'Change Role'\n : confirmAction === 'role' ? 'Change Role'\n : 'Delete'\n }\n variant={confirmAction === 'delete' || confirmAction === 'bulkDelete' ? 'danger' : 'primary'}\n onConfirm={handleConfirm}\n onCancel={handleCancel}\n />\n{/if}\n",
|
|
43
|
+
"/routes/(protected)/admin/users/page.server.test.ts": "/**\n * Admin Users Page Server Tests\n *\n * Tests that the load function returns the mock users array with correct shape.\n */\nimport { describe, it, expect } from 'vitest';\n\nconst mod = await import('./+page.server');\n\ndescribe('admin/users +page.server.ts', () => {\n it('returns a users array', async () => {\n const result = await mod.load();\n expect(Array.isArray(result.users)).toBe(true);\n });\n\n it('returns the expected number of users (15)', async () => {\n const result = await mod.load();\n expect(result.users).toHaveLength(15);\n });\n\n it('each user has the correct shape (id, name, email, role, createdAt)', async () => {\n const result = await mod.load();\n\n for (const user of result.users) {\n expect(user).toHaveProperty('id');\n expect(user).toHaveProperty('name');\n expect(user).toHaveProperty('email');\n expect(user).toHaveProperty('role');\n expect(user).toHaveProperty('createdAt');\n\n expect(typeof user.id).toBe('string');\n expect(typeof user.name).toBe('string');\n expect(typeof user.email).toBe('string');\n expect(['admin', 'user']).toContain(user.role);\n expect(typeof user.createdAt).toBe('string');\n }\n });\n\n it('contains expected admin users', async () => {\n const result = await mod.load();\n const admins = result.users.filter((u) => u.role === 'admin');\n\n expect(admins.length).toBeGreaterThan(0);\n expect(admins.every((u) => u.role === 'admin')).toBe(true);\n expect(admins.map((u) => u.name)).toContain('Alice Johnson');\n });\n\n it('contains expected regular users', async () => {\n const result = await mod.load();\n const regularUsers = result.users.filter((u) => u.role === 'user');\n\n expect(regularUsers.length).toBeGreaterThan(0);\n expect(regularUsers.map((u) => u.name)).toContain('Bob Smith');\n });\n\n it('ids are unique', async () => {\n const result = await mod.load();\n const ids = result.users.map((u) => u.id);\n const uniqueIds = new Set(ids);\n\n expect(uniqueIds.size).toBe(ids.length);\n });\n});\n",
|
|
44
|
+
"/routes/(legal)/legal/+page.svelte": "<script lang=\"ts\">\n import { Card } from '$lib/components/ui';\n const currentYear = new Date().getFullYear();\n<\/script>\n\n<svelte:head>\n <title>Legal Notice - SvelteForge</title>\n</svelte:head>\n\n<div class=\"container mx-auto px-4 max-w-4xl py-12\">\n <h1 class=\"text-3xl font-black mb-8 uppercase tracking-tight\">Legal Notice</h1>\n\n <Card padding=\"p-8 sm:p-12\">\n <div class=\"space-y-10\">\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 1. Publisher\n </h2>\n <div class=\"text-surface-600-400 leading-relaxed\">\n This website is published by:<br />\n <strong class=\"text-surface-900-100\">[Company name]</strong><br />\n [Legal form] with a capital of [amount]<br />\n Registered office: [address]<br />\n SIRET: [SIRET number]<br />\n Phone: [phone]<br />\n Email: [email]<br />\n Publication director: [name]\n </div>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 2. Hosting\n </h2>\n <div class=\"text-surface-600-400 leading-relaxed\">\n This website is hosted by:<br />\n <strong class=\"text-surface-900-100\">[Hosting provider]</strong><br />\n [Hosting provider address]<br />\n [Hosting provider phone]\n </div>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 3. Intellectual property\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n All content on this website (texts, images, videos, logos, etc.) is protected by\n copyright and trademark law. Any reproduction, representation, modification,\n publication, or adaptation of all or part of the site's elements is prohibited without\n prior written authorization.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 4. Personal data\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n For any information regarding the processing of your personal data, please consult our\n <a href=\"/privacy\" class=\"text-primary-500 hover:underline font-bold\">privacy policy</a>.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 5. Cookies\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n This website uses cookies to improve the user experience. By continuing to browse this\n site, you accept the use of cookies.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 6. Contact\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n For any questions regarding this legal notice, you can contact us at: [contact email]\n </p>\n </section>\n </div>\n\n <div class=\"mt-12 pt-8 border-t border-surface-200-700\">\n <p class=\"text-xs text-surface-500 uppercase font-bold tracking-widest\">\n Last updated: {currentYear}\n </p>\n </div>\n </Card>\n</div>\n",
|
|
45
|
+
"/routes/(legal)/+layout.svelte": "<script lang=\"ts\">\n let { children } = $props();\n<\/script>\n\n{@render children()}\n",
|
|
46
|
+
"/routes/(legal)/privacy/+page.svelte": "<script lang=\"ts\">\n import { Card } from '$lib/components/ui';\n const currentYear = new Date().getFullYear();\n<\/script>\n\n<svelte:head>\n <title>Privacy Policy - SvelteForge</title>\n</svelte:head>\n\n<div class=\"container mx-auto px-4 max-w-4xl py-12\">\n <h1 class=\"text-3xl font-black mb-8 uppercase tracking-tight\">Privacy Policy</h1>\n\n <Card padding=\"p-8 sm:p-12\">\n <div class=\"space-y-10\">\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 1. Introduction\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n This privacy policy describes how we collect, use, and protect your personal information\n when you use our website and services.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 2. Data collected\n </h2>\n <p class=\"text-surface-600-400 mb-4 leading-relaxed\">\n We may collect the following types of data:\n </p>\n <ul\n class=\"list-disc list-inside text-surface-600-400 space-y-2 ml-2\"\n >\n <li>Identification information (name, email)</li>\n <li>Connection information (IP address, browser, device)</li>\n <li>Service usage data</li>\n </ul>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 3. Purpose of processing\n </h2>\n <p class=\"text-surface-600-400 mb-4 leading-relaxed\">\n Your data is used to:\n </p>\n <ul\n class=\"list-disc list-inside text-surface-600-400 space-y-2 ml-2\"\n >\n <li>Manage your user account</li>\n <li>Provide our services</li>\n <li>Improve our service</li>\n <li>Comply with our legal obligations</li>\n </ul>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 4. Legal basis\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n The processing of your personal data is based on the execution of the contract,\n compliance with our legal obligations, and your consent.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 5. Retention period\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n Your data is retained for the duration of your registration, plus the retention periods\n required by law.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 6. Your rights\n </h2>\n <p class=\"text-surface-600-400 mb-4 leading-relaxed\">\n Under GDPR, you have the following rights:\n </p>\n <ul\n class=\"list-disc list-inside text-surface-600-400 space-y-2 ml-2\"\n >\n <li>Right of access to your data</li>\n <li>Right to rectification</li>\n <li>Right to erasure</li>\n <li>Right to data portability</li>\n <li>Right to object</li>\n </ul>\n <p class=\"text-surface-600-400 mt-4 leading-relaxed italic\">\n To exercise these rights, contact us at: [email]\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 7. Security\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n We implement appropriate technical and organizational measures to protect your personal\n data against unauthorized access, modification, disclosure, or destruction.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 8. Cookies\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n Our website uses cookies to ensure proper functioning and improve the user experience.\n You can configure your browser to refuse cookies.\n </p>\n </section>\n\n <section>\n <h2\n class=\"text-xl font-bold mb-4 uppercase tracking-wide text-primary-600-400\"\n >\n 9. Contact\n </h2>\n <p class=\"text-surface-600-400 leading-relaxed\">\n For any questions regarding this policy, contact our DPO at: [email]\n </p>\n </section>\n </div>\n\n <div class=\"mt-12 pt-8 border-t border-surface-200-700\">\n <p class=\"text-xs text-surface-500 uppercase font-bold tracking-widest\">\n Last updated: {currentYear}\n </p>\n </div>\n </Card>\n</div>\n",
|
|
47
|
+
"/routes/+layout.svelte": "<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { page } from '$app/stores';\n import { Footer, Navbar } from '$lib/components';\n import { themeStore } from '$lib/utils/theme.svelte';\n import '../app.css';\n\n interface LayoutData {\n user?: { id: string; name?: string; email: string; role?: string; image?: string } | null;\n }\n\n let { children, data }: { children: any; data: LayoutData } = $props();\n\n onMount(() => {\n themeStore.init();\n });\n\n let isAdminPage = $derived($page.url.pathname.startsWith('/admin'));\n let isAuthPage = $derived(\n $page.url.pathname.startsWith('/login') || $page.url.pathname.startsWith('/signup')\n );\n let hideChrome = $derived(isAdminPage || isAuthPage);\n<\/script>\n\n<div class=\"flex flex-col min-h-screen\">\n {#if !hideChrome}\n <Navbar user={data.user} />\n {/if}\n\n <main class=\"flex-1 {!hideChrome ? 'pt-16' : ''}\">\n {@render children()}\n </main>\n\n {#if !hideChrome}\n <Footer />\n {/if}\n</div>\n",
|
|
48
|
+
"/routes/+page.svelte": "<script lang=\"ts\">\n import Button from '$lib/components/ui/Button.svelte';\n<\/script>\n\n<svelte:head>\n <title>Welcome</title>\n <meta name=\"description\" content=\"Welcome\" />\n</svelte:head>\n\n<main class=\"min-h-screen flex items-center justify-center bg-surface-50-950\">\n <div class=\"max-w-lg mx-auto px-4 text-center space-y-8\">\n <div>\n <h1 class=\"text-4xl sm:text-5xl font-black uppercase tracking-tight\">\n Welcome\n </h1>\n <p class=\"text-lg text-surface-500 mt-4\">\n Sign in to access your dashboard.\n </p>\n </div>\n\n <div class=\"flex flex-col sm:flex-row gap-3 justify-center\">\n <Button href=\"/login\" variant=\"primary\" size=\"lg\">Sign In</Button>\n <Button href=\"/signup\" variant=\"outline\" size=\"lg\">Create Account</Button>\n </div>\n </div>\n</main>\n",
|
|
49
|
+
"/routes/(public)/forgot-password/+page.server.ts": "import { fail } from '@sveltejs/kit';\nimport { superValidate } from 'sveltekit-superforms';\nimport { zod4 } from 'sveltekit-superforms/adapters';\nimport { message } from 'sveltekit-superforms';\nimport { passwordForgotSchema } from '$lib/schemas';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async () => {\n return {\n form: await superValidate(zod4(passwordForgotSchema))\n };\n};\n\nexport const actions: Actions = {\n default: async ({ request }) => {\n const form = await superValidate(request, zod4(passwordForgotSchema));\n if (!form.valid) return fail(400, { form });\n\n const { email } = form.data;\n\n // TODO: Wire up better-auth password reset at install time\n // Example:\n // await authClient.forgetPassword({ email, redirectTo: '/reset-password' });\n\n // Mock: always return success to avoid leaking registered emails\n return message(form, 'If an account exists with that email, a reset link has been sent.');\n }\n};\n",
|
|
50
|
+
"/routes/(public)/forgot-password/+page.svelte": "<script lang=\"ts\">\n import { superForm } from 'sveltekit-superforms';\n import { zod4Client } from 'sveltekit-superforms/adapters';\n import { passwordForgotSchema } from '$lib/schemas';\n import { AuthCard, Button, FormField } from '$lib/components';\n\n let { data } = $props();\n\n const { form, errors, enhance, submitting, message } = superForm(data.form, {\n validators: zod4Client(passwordForgotSchema),\n dataType: 'json',\n invalidateAll: false,\n TTL: 0\n });\n<\/script>\n\n<AuthCard title=\"Forgot password\" subtitle=\"Enter your email to receive a reset link\">\n <form method=\"POST\" use:enhance class=\"space-y-4\">\n {#if $message}\n <p class=\"rounded-lg bg-surface-100-800 p-3 text-center text-success-700-300\" style=\"font-size: var(--text-sm)\">\n {$message}\n </p>\n {/if}\n\n <FormField\n label=\"Email\"\n id=\"email\"\n type=\"email\"\n name=\"email\"\n placeholder=\"you@example.com\"\n required\n bind:value={$form.email}\n error={$errors.email}\n />\n\n <Button\n type=\"submit\"\n variant=\"primary\"\n size=\"lg\"\n loading={$submitting}\n class=\"w-full\"\n style=\"font-weight: var(--weight-label)\"\n >\n Send Reset Link\n </Button>\n </form>\n\n {#snippet footer()}\n <p class=\"text-center text-surface-600-400\" style=\"font-size: var(--text-body)\">\n Remember your password?\n <Button variant=\"ghost\" size=\"sm\" href=\"/login\" class=\"text-primary-600-400 hover:text-primary-700-300\">\n Sign in\n </Button>\n </p>\n {/snippet}\n</AuthCard>\n",
|
|
51
|
+
"/routes/(public)/+layout.svelte": "<script lang=\"ts\">\n let { children } = $props();\n<\/script>\n\n{@render children()}\n",
|
|
52
|
+
"/routes/(public)/signup/+page.server.ts": "import { fail, redirect } from '@sveltejs/kit';\nimport { superValidate } from 'sveltekit-superforms';\nimport { zod4 } from 'sveltekit-superforms/adapters';\nimport { signupSchema } from '$lib/schemas';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n // Redirect to dashboard if already logged in\n if (locals.user) {\n redirect(302, '/dashboard');\n }\n\n return {\n form: await superValidate(zod4(signupSchema))\n };\n};\n\nexport const actions: Actions = {\n default: async ({ request, locals }) => {\n const form = await superValidate(request, zod4(signupSchema));\n if (!form.valid) return fail(400, { form });\n\n const { name, email, password } = form.data;\n\n // TODO: Wire up better-auth sign-up at install time\n // Example:\n // const result = await authClient.signUp.email({ email, password, name });\n // if (result.error) {\n // return setError(form, '', result.error.message || 'Could not create account');\n // }\n\n // Placeholder: redirect on success\n throw redirect(303, '/dashboard');\n }\n};\n",
|
|
53
|
+
"/routes/(public)/signup/+page.svelte": "<script lang=\"ts\">\n import { superForm } from 'sveltekit-superforms';\n import { zod4Client } from 'sveltekit-superforms/adapters';\n import { signupSchema } from '$lib/schemas';\n import { AuthCard, Button, FormField, PasswordInput } from '$lib/components';\n\n let { data } = $props();\n\n const { form, errors, enhance, submitting } = superForm(data.form, {\n validators: zod4Client(signupSchema),\n dataType: 'json',\n invalidateAll: false,\n TTL: 0\n });\n<\/script>\n\n<AuthCard title=\"Create account\" subtitle=\"Get started with SvelteForge\">\n <form method=\"POST\" use:enhance class=\"space-y-4\">\n <FormField\n label=\"Name\"\n id=\"name\"\n type=\"text\"\n name=\"name\"\n placeholder=\"Your name\"\n bind:value={$form.name}\n error={$errors.name}\n />\n\n <FormField\n label=\"Email\"\n id=\"email\"\n type=\"email\"\n name=\"email\"\n placeholder=\"you@example.com\"\n required\n bind:value={$form.email}\n error={$errors.email}\n />\n\n <PasswordInput\n id=\"password\"\n label=\"Password\"\n name=\"password\"\n placeholder=\"••••••••\"\n required\n showStrength\n bind:value={$form.password}\n error={$errors.password}\n />\n\n <PasswordInput\n id=\"confirmPassword\"\n label=\"Confirm password\"\n name=\"confirmPassword\"\n placeholder=\"••••••••\"\n required\n bind:value={$form.confirmPassword}\n error={$errors.confirmPassword}\n />\n\n <Button\n type=\"submit\"\n variant=\"primary\"\n size=\"lg\"\n loading={$submitting}\n class=\"w-full\"\n style=\"font-weight: var(--weight-label)\"\n >\n Create Account\n </Button>\n </form>\n\n {#snippet footer()}\n <p class=\"text-center text-surface-600-400\" style=\"font-size: var(--text-body)\">\n Already have an account?\n <Button variant=\"ghost\" size=\"sm\" href=\"/login\" class=\"text-primary-600-400 hover:text-primary-700-300\">\n Sign in\n </Button>\n </p>\n {/snippet}\n</AuthCard>\n",
|
|
54
|
+
"/routes/(public)/login/+page.server.ts": "import { fail, redirect } from '@sveltejs/kit';\nimport { superValidate } from 'sveltekit-superforms';\nimport { zod4 } from 'sveltekit-superforms/adapters';\nimport { loginSchema } from '$lib/schemas';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async ({ locals }) => {\n // Redirect to dashboard if already logged in\n if (locals.user) {\n redirect(302, '/dashboard');\n }\n\n return {\n form: await superValidate(zod4(loginSchema))\n };\n};\n\nexport const actions: Actions = {\n default: async ({ request, locals }) => {\n const form = await superValidate(request, zod4(loginSchema));\n if (!form.valid) return fail(400, { form });\n\n const { email, password } = form.data;\n\n // TODO: Wire up better-auth sign-in at install time\n // Example:\n // const result = await authClient.signIn.email({ email, password });\n // if (result.error) {\n // return setError(form, '', result.error.message || 'Invalid credentials');\n // }\n\n // Placeholder: redirect on success\n throw redirect(303, '/dashboard');\n }\n};\n",
|
|
55
|
+
"/routes/(public)/login/+page.svelte": "<script lang=\"ts\">\n import { superForm } from 'sveltekit-superforms';\n import { zod4Client } from 'sveltekit-superforms/adapters';\n import { loginSchema } from '$lib/schemas';\n import { AuthCard, Button, FormField, PasswordInput } from '$lib/components';\n\n let { data } = $props();\n\n const { form, errors, enhance, submitting } = superForm(data.form, {\n validators: zod4Client(loginSchema),\n dataType: 'json',\n invalidateAll: false,\n TTL: 0\n });\n<\/script>\n\n<AuthCard title=\"Welcome back\" subtitle=\"Sign in to your account\">\n <form method=\"POST\" use:enhance class=\"space-y-4\">\n <FormField\n label=\"Email\"\n id=\"email\"\n type=\"email\"\n name=\"email\"\n placeholder=\"you@example.com\"\n required\n bind:value={$form.email}\n error={$errors.email}\n />\n\n <PasswordInput\n id=\"password\"\n label=\"Password\"\n name=\"password\"\n placeholder=\"••••••••\"\n required\n bind:value={$form.password}\n error={$errors.password}\n />\n\n <div class=\"flex justify-end\">\n <Button variant=\"ghost\" size=\"sm\" href=\"/forgot-password\" class=\"text-primary-600-400 hover:text-primary-700-300\">\n Forgot password?\n </Button>\n </div>\n\n <Button\n type=\"submit\"\n variant=\"primary\"\n size=\"lg\"\n loading={$submitting}\n class=\"w-full\"\n style=\"font-weight: var(--weight-label)\"\n >\n Sign In\n </Button>\n </form>\n\n {#snippet footer()}\n <p class=\"text-center text-surface-600-400\" style=\"font-size: var(--text-body)\">\n Don't have an account?\n <Button variant=\"ghost\" size=\"sm\" href=\"/signup\" class=\"text-primary-600-400 hover:text-primary-700-300\">\n Sign up\n </Button>\n </p>\n {/snippet}\n</AuthCard>\n",
|
|
56
|
+
"/routes/(public)/reset-password/+page.server.ts": "import { fail } from '@sveltejs/kit';\nimport { superValidate } from 'sveltekit-superforms';\nimport { zod4 } from 'sveltekit-superforms/adapters';\nimport { message } from 'sveltekit-superforms';\nimport { passwordResetSchema } from '$lib/schemas';\nimport type { PageServerLoad, Actions } from './$types';\n\nexport const load: PageServerLoad = async () => {\n return {\n form: await superValidate(zod4(passwordResetSchema))\n };\n};\n\nexport const actions: Actions = {\n default: async ({ request }) => {\n const form = await superValidate(request, zod4(passwordResetSchema));\n if (!form.valid) return fail(400, { form });\n\n const { password } = form.data;\n\n // TODO: Wire up better-auth password reset confirmation at install time\n // Example:\n // const result = await authClient.resetPassword({ newPassword: password, token });\n // if (result.error) {\n // return setError(form, '', result.error.message || 'Failed to reset password');\n // }\n\n // Mock: return success message\n return message(form, 'Your password has been reset successfully.');\n }\n};\n",
|
|
57
|
+
"/routes/(public)/reset-password/+page.svelte": "<script lang=\"ts\">\n import { superForm } from 'sveltekit-superforms';\n import { zod4Client } from 'sveltekit-superforms/adapters';\n import { passwordResetSchema } from '$lib/schemas';\n import { AuthCard, Button, PasswordInput } from '$lib/components';\n\n let { data } = $props();\n\n const { form, errors, enhance, submitting, message } = superForm(data.form, {\n validators: zod4Client(passwordResetSchema),\n dataType: 'json',\n invalidateAll: false,\n TTL: 0\n });\n<\/script>\n\n<AuthCard title=\"Reset password\" subtitle=\"Enter your new password below\">\n <form method=\"POST\" use:enhance class=\"space-y-4\">\n {#if $message}\n <p class=\"rounded-lg bg-surface-100-800 p-3 text-center text-success-700-300\" style=\"font-size: var(--text-sm)\">\n {$message}\n </p>\n {/if}\n\n <PasswordInput\n id=\"password\"\n label=\"New password\"\n name=\"password\"\n placeholder=\"••••••••\"\n required\n showStrength\n bind:value={$form.password}\n error={$errors.password}\n />\n\n <PasswordInput\n id=\"confirmPassword\"\n label=\"Confirm new password\"\n name=\"confirmPassword\"\n placeholder=\"••••••••\"\n required\n bind:value={$form.confirmPassword}\n error={$errors.confirmPassword}\n />\n\n <Button\n type=\"submit\"\n variant=\"primary\"\n size=\"lg\"\n loading={$submitting}\n class=\"w-full\"\n style=\"font-weight: var(--weight-label)\"\n >\n Reset Password\n </Button>\n </form>\n\n {#snippet footer()}\n <p class=\"text-center text-surface-600-400\" style=\"font-size: var(--text-body)\">\n Back to\n <Button variant=\"ghost\" size=\"sm\" href=\"/login\" class=\"text-primary-600-400 hover:text-primary-700-300\">\n Sign in\n </Button>\n </p>\n {/snippet}\n</AuthCard>\n",
|
|
58
|
+
"/routes/+error.svelte": "<script lang=\"ts\">\n import { page } from '$app/stores';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n const status = $derived($page.status);\n const message = $derived($page.error?.message);\n\n const errorConfig = $derived(\n status === 404\n ? {\n code: '404',\n title: 'Page Not Found',\n description: \"The page you're looking for doesn't exist or has been moved.\",\n icon: 'search',\n action: { label: 'Go Home', href: '/' }\n }\n : {\n code: status.toString(),\n title: 'Something Went Wrong',\n description: 'An unexpected error occurred. Please try again later.',\n icon: 'alertCircle',\n action: { label: 'Retry', href: $page.url.pathname }\n }\n );\n<\/script>\n\n<svelte:head>\n <title>{errorConfig.code} - {errorConfig.title}</title>\n <meta name=\"description\" content={errorConfig.description} />\n</svelte:head>\n\n<div class=\"min-h-screen flex items-center justify-center bg-surface-50-950 px-4 py-16\">\n <div class=\"card p-8 sm:p-12 text-center max-w-lg\">\n <div class=\"inline-flex items-center justify-center w-24 h-24 rounded-full bg-primary-500/10 mb-8\">\n <Icon name={errorConfig.icon} size={48} class=\"text-primary-500\" />\n </div>\n\n <div class=\"space-y-4\">\n <p class=\"text-8xl font-black text-primary-500/20\">{errorConfig.code}</p>\n <h1 class=\"text-2xl sm:text-3xl font-bold\">{errorConfig.title}</h1>\n <p class=\"text-surface-600-400 text-base\">{errorConfig.description}</p>\n\n {#if status !== 404 && message}\n <div class=\"mt-6 p-4 bg-surface-50-900/50 border border-surface-300-700 rounded-xl text-left\">\n <p class=\"uppercase font-bold text-surface-500 mb-1 tracking-wider\" style=\"font-size: var(--text-caption)\">Error Details</p>\n <p class=\"text-xs font-mono text-error-500 break-all leading-relaxed\">{message}</p>\n </div>\n {/if}\n </div>\n\n <div class=\"mt-10 flex flex-col sm:flex-row gap-3 justify-center\">\n <a href={errorConfig.action.href} class=\"btn btn-lg preset-filled-primary-500 px-8\">\n {errorConfig.action.label}\n </a>\n <a href=\"/\" class=\"btn btn-lg preset-outlined-primary-500 px-8\">Go Home</a>\n </div>\n </div>\n</div>\n",
|
|
59
|
+
"/lib/utils/export.ts": "/**\n * CSV export utility\n */\n\nexport interface ExportColumn<T> {\n /** Property key on the data object */\n key: keyof T & string;\n /** Column header label */\n label: string;\n /** Optional transform applied before writing */\n format?: (value: T[keyof T & string], row: T) => string;\n}\n\n/**\n * Escape a single CSV cell value.\n * Wraps in double-quotes when the value contains commas, quotes, or newlines.\n */\nfunction escapeCSV(value: string): string {\n if (value.includes('\"') || value.includes(',') || value.includes('\\n') || value.includes('\\r')) {\n return `\"${value.replace(/\"/g, '\"\"')}\"`;\n }\n return value;\n}\n\n/**\n * Export an array of objects as a downloadable CSV file.\n *\n * @param data - Rows to export\n * @param filename - Download filename (without extension)\n * @param columns - Column definitions controlling order, headers, and formatting\n */\nexport function exportToCSV<T extends Record<string, unknown>>(\n data: T[],\n filename: string,\n columns: ExportColumn<T>[]\n): void {\n const header = columns.map((col) => escapeCSV(col.label)).join(',');\n const rows = data.map((row) =>\n columns\n .map((col) => {\n const raw = row[col.key];\n const formatted = col.format ? col.format(raw, row) : String(raw ?? '');\n return escapeCSV(formatted);\n })\n .join(',')\n );\n\n const csv = [header, ...rows].join('\\n');\n const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });\n const url = URL.createObjectURL(blob);\n\n const link = document.createElement('a');\n link.href = url;\n link.download = `${filename}.csv`;\n link.style.display = 'none';\n document.body.appendChild(link);\n link.click();\n document.body.removeChild(link);\n URL.revokeObjectURL(url);\n}\n",
|
|
60
|
+
"/lib/utils/formatters.test.ts": "import { describe, it, expect } from 'vitest';\nimport { formatDateShort, formatDateTime, formatUserName } from '$lib/utils/formatters';\n\ndescribe('formatDateShort', () => {\n it('formats a date with month abbreviation, day, and year', () => {\n const date = new Date('2026-01-25T10:00:00Z');\n const result = formatDateShort(date);\n // Result depends on locale, but should contain key parts\n expect(result).toContain('2026');\n expect(result).toContain('25');\n });\n\n it('handles Date objects', () => {\n const result = formatDateShort(new Date('2026-07-04'));\n expect(result).toContain('2026');\n });\n});\n\ndescribe('formatDateTime', () => {\n it('returns \"-\" for null', () => {\n expect(formatDateTime(null)).toBe('-');\n });\n\n it('returns \"-\" for undefined', () => {\n expect(formatDateTime(undefined)).toBe('-');\n });\n\n it('formats a date with time', () => {\n const date = new Date('2026-01-25T14:30:00');\n const result = formatDateTime(date);\n expect(result).toContain('2026');\n expect(result).toContain('25');\n });\n});\n\ndescribe('formatUserName', () => {\n it('joins first and last name', () => {\n expect(formatUserName('John', 'Doe')).toBe('John Doe');\n });\n\n it('returns only first name if last is null', () => {\n expect(formatUserName('John', null)).toBe('John');\n });\n\n it('returns only last name if first is null', () => {\n expect(formatUserName(null, 'Doe')).toBe('Doe');\n });\n\n it('returns null when both parts are empty/null', () => {\n expect(formatUserName(null, null)).toBeNull();\n expect(formatUserName(undefined, undefined)).toBeNull();\n expect(formatUserName('', '')).toBeNull();\n });\n\n it('preserves whitespace in names (filter(Boolean) keeps them)', () => {\n // filter(Boolean) keeps strings with whitespace, join adds a space\n const result = formatUserName(' John ', ' Doe ');\n expect(result).toBe(' John Doe ');\n });\n});\n",
|
|
61
|
+
"/lib/utils/formatters.ts": "/**\n * Shared formatting utilities\n */\n\n/**\n * Format a date in short format\n * Example: \"Jan 25, 2026\"\n */\nexport function formatDateShort(date: Date): string {\n return new Date(date).toLocaleDateString(undefined, {\n day: 'numeric',\n month: 'short',\n year: 'numeric'\n });\n}\n\n/**\n * Format a date with time\n * Example: \"Jan 25, 2026, 14:30\"\n * Returns '-' for null/undefined dates.\n */\nexport function formatDateTime(date: Date | null | undefined): string {\n if (!date) return '-';\n return new Date(date).toLocaleDateString(undefined, {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n hour: '2-digit',\n minute: '2-digit'\n });\n}\n\n/**\n * Format a user's display name from first and last name parts.\n * Returns \"First Last\" or null if both parts are empty.\n */\nexport function formatUserName(\n firstName: string | null | undefined,\n lastName: string | null | undefined\n): string | null {\n return [firstName, lastName].filter(Boolean).join(' ') || null;\n}\n",
|
|
62
|
+
"/lib/utils/focus-trap.ts": "/**\n * Svelte action: traps focus inside a modal/dialog element.\n * Moves focus to the first focusable element on mount.\n * Wraps Tab/Shift+Tab to prevent focus leaving the container.\n */\n\nconst FOCUSABLE_SELECTORS = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])'\n].join(', ');\n\nfunction getFocusableElements(container: HTMLElement): HTMLElement[] {\n return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)).filter(\n (el) => el.offsetParent !== null // exclude hidden elements\n );\n}\n\nexport function focusTrap(node: HTMLElement) {\n // Focus the first focusable element when the modal opens\n const firstFocusable = getFocusableElements(node)[0];\n firstFocusable?.focus();\n\n function handleKeydown(e: KeyboardEvent) {\n if (e.key !== 'Tab') return;\n\n const focusable = getFocusableElements(node);\n if (!focusable.length) return;\n\n const first = focusable[0];\n const last = focusable[focusable.length - 1];\n\n if (e.shiftKey) {\n if (document.activeElement === first) {\n e.preventDefault();\n last.focus();\n }\n } else {\n if (document.activeElement === last) {\n e.preventDefault();\n first.focus();\n }\n }\n }\n\n node.addEventListener('keydown', handleKeydown);\n\n return {\n destroy() {\n node.removeEventListener('keydown', handleKeydown);\n }\n };\n}\n",
|
|
63
|
+
"/lib/utils/form-errors.ts": "/**\n * Utility functions for handling form errors from Superforms/Zod 4\n */\n\n/**\n * Extracts the first error message from a Superforms/Zod 4 error object\n *\n * Zod 4 uses { _errors: string[] } format for error objects\n *\n * @param err - Error value from Superforms ($errors.fieldName)\n * @returns First error message or empty string\n */\nexport function getFormError(err: unknown): string {\n if (!err) return '';\n if (typeof err === 'string') return err;\n if (Array.isArray(err)) return err[0] || '';\n if (typeof err === 'object' && '_errors' in err) {\n const errors = (err as { _errors?: string[] })._errors;\n if (errors && errors.length) return errors[0];\n }\n return '';\n}\n",
|
|
64
|
+
"/lib/utils/slugify.ts": "/**\n * Convertit une chaîne en slug (URL-friendly)\n * @param str La chaîne à slugifier\n * @returns Le slug\n */\nexport function slugify(str: string): string {\n return str\n .toLowerCase()\n .normalize('NFD') // Décompose les caractères accentués\n .replace(/[\\u0300-\\u036f]/g, '') // Supprime les diacritiques\n .replace(/[^a-z0-9]+/g, '-') // Remplace les caractères non-alphanumériques par des tirets\n .replace(/^-+|-+$/g, ''); // Supprime les tirets au début et à la fin\n}\n",
|
|
65
|
+
"/lib/utils/slugify.test.ts": "import { describe, it, expect } from 'vitest';\nimport { slugify } from '$lib/utils/slugify';\n\ndescribe('slugify', () => {\n it('converts a basic string to a slug', () => {\n expect(slugify('Hello World')).toBe('hello-world');\n });\n\n it('handles special characters', () => {\n expect(slugify('Hello, World!')).toBe('hello-world');\n expect(slugify('foo@bar.com')).toBe('foo-bar-com');\n });\n\n it('handles accented/unicode characters (NFD decomposition)', () => {\n expect(slugify('Café au lait')).toBe('cafe-au-lait');\n expect(slugify('résumé')).toBe('resume');\n });\n\n it('lowercases the string', () => {\n expect(slugify('UPPERCASE')).toBe('uppercase');\n expect(slugify('MiXeD CaSe')).toBe('mixed-case');\n });\n\n it('removes leading and trailing dashes', () => {\n expect(slugify('--hello--')).toBe('hello');\n expect(slugify('---test---')).toBe('test');\n });\n\n it('collapses multiple non-alphanumeric chars into single dash', () => {\n expect(slugify('foo bar')).toBe('foo-bar');\n expect(slugify('a---b')).toBe('a-b');\n });\n\n it('returns empty string for empty input', () => {\n expect(slugify('')).toBe('');\n });\n\n it('handles strings with only special characters', () => {\n expect(slugify('!!!')).toBe('');\n expect(slugify('---')).toBe('');\n });\n});\n",
|
|
66
|
+
"/lib/utils/form-errors.test.ts": "import { describe, it, expect } from 'vitest';\nimport { getFormError } from '$lib/utils/form-errors';\n\ndescribe('getFormError', () => {\n it('returns empty string for falsy values', () => {\n expect(getFormError(null)).toBe('');\n expect(getFormError(undefined)).toBe('');\n expect(getFormError('')).toBe('');\n expect(getFormError(0)).toBe('');\n expect(getFormError(false)).toBe('');\n });\n\n it('returns the string directly', () => {\n expect(getFormError('Required field')).toBe('Required field');\n });\n\n it('returns first element from array', () => {\n expect(getFormError(['Error 1', 'Error 2'])).toBe('Error 1');\n });\n\n it('returns empty string for empty array', () => {\n expect(getFormError([])).toBe('');\n });\n\n it('extracts from Zod 4 style { _errors: string[] } object', () => {\n expect(getFormError({ _errors: ['Invalid email'] })).toBe('Invalid email');\n });\n\n it('returns first _errors entry', () => {\n expect(getFormError({ _errors: ['Too short', 'Too long'] })).toBe('Too short');\n });\n\n it('returns empty string for object without _errors', () => {\n expect(getFormError({ foo: 'bar' })).toBe('');\n });\n\n it('returns empty string for _errors with empty array', () => {\n expect(getFormError({ _errors: [] })).toBe('');\n });\n});\n",
|
|
67
|
+
"/lib/utils/theme.svelte.ts": "/**\n * Theme store - Svelte 5 rune-based theme management\n * Uses Skeleton's native data-mode attribute for dark/light mode\n */\n\nconst THEME_KEY = 'svelteforge-theme';\n\nlet isDark = $state(false);\nlet initialized = false;\n\nfunction initTheme() {\n if (initialized || typeof window === 'undefined') return;\n initialized = true;\n\n const stored = localStorage.getItem(THEME_KEY);\n if (stored) {\n isDark = stored === 'dark';\n } else {\n isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n }\n applyTheme();\n\n // Listen for system preference changes\n window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {\n if (!localStorage.getItem(THEME_KEY)) {\n isDark = e.matches;\n applyTheme();\n }\n });\n}\n\nfunction applyTheme() {\n if (typeof document === 'undefined') return;\n document.documentElement.setAttribute('data-mode', isDark ? 'dark' : 'light');\n}\n\nfunction toggleTheme() {\n isDark = !isDark;\n localStorage.setItem(THEME_KEY, isDark ? 'dark' : 'light');\n applyTheme();\n}\n\nexport const themeStore = {\n get isDark() { return isDark; },\n init: initTheme,\n toggle: toggleTheme,\n destroy: () => { initialized = false; }\n};\n",
|
|
68
|
+
"/lib/schemas/login.ts": "import { z } from 'zod/v4';\n\nexport const loginSchema = z.object({\n email: z.string().email('Invalid email address'),\n password: z.string().min(8, 'Password must be at least 8 characters'),\n});\n",
|
|
69
|
+
"/lib/schemas/signup.test.ts": "import { describe, it, expect } from 'vitest';\nimport { signupSchema } from '$lib/schemas/signup';\n\ndescribe('signupSchema', () => {\n const validData = {\n email: 'user@example.com',\n password: 'password123',\n confirmPassword: 'password123'\n };\n\n it('validates correct data', () => {\n const result = signupSchema.safeParse(validData);\n expect(result.success).toBe(true);\n });\n\n it('validates with optional name', () => {\n const result = signupSchema.safeParse({ ...validData, name: 'John' });\n expect(result.success).toBe(true);\n });\n\n it('rejects missing email', () => {\n const { email, ...noEmail } = validData;\n const result = signupSchema.safeParse(noEmail);\n expect(result.success).toBe(false);\n });\n\n it('rejects invalid email', () => {\n const result = signupSchema.safeParse({ ...validData, email: 'not-an-email' });\n expect(result.success).toBe(false);\n });\n\n it('rejects short password (< 8 chars)', () => {\n const result = signupSchema.safeParse({ ...validData, password: 'short', confirmPassword: 'short' });\n expect(result.success).toBe(false);\n });\n\n it('rejects mismatched passwords', () => {\n const result = signupSchema.safeParse({ ...validData, confirmPassword: 'different123' });\n expect(result.success).toBe(false);\n if (!result.success) {\n const confirmError = result.error.issues.find((i) => i.path.includes('confirmPassword'));\n expect(confirmError).toBeDefined();\n expect(confirmError!.message).toBe('Passwords do not match');\n }\n });\n\n it('rejects missing confirmPassword', () => {\n const { confirmPassword, ...noConfirm } = validData;\n const result = signupSchema.safeParse(noConfirm);\n expect(result.success).toBe(false);\n });\n\n it('name shorter than 2 chars is rejected when provided', () => {\n const result = signupSchema.safeParse({ ...validData, name: 'A' });\n expect(result.success).toBe(false);\n });\n});\n",
|
|
70
|
+
"/lib/schemas/password.ts": "import { z } from 'zod/v4';\n\nexport const passwordResetSchema = z\n .object({\n password: z.string().min(8, 'Password must be at least 8 characters'),\n confirmPassword: z.string().min(8, 'Password must be at least 8 characters'),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: 'Passwords do not match',\n path: ['confirmPassword'],\n });\n\nexport const passwordForgotSchema = z.object({\n email: z.string().email('Invalid email address'),\n});\n\nexport const changePasswordSchema = z\n .object({\n currentPassword: z.string().min(1, { error: 'Current password is required' }),\n password: z.string().min(8, { error: 'Password must be at least 8 characters' }),\n confirmPassword: z.string().min(8, { error: 'Password must be at least 8 characters' }),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: 'Passwords do not match',\n path: ['confirmPassword'],\n });\n",
|
|
71
|
+
"/lib/schemas/index.ts": "/**\n * Zod validation schemas for Superforms\n *\n * This directory contains all validation schemas used in the application.\n * Schemas are organized by domain.\n *\n * Usage:\n * ```typescript\n * import { loginSchema } from '$lib/schemas';\n * import { superValidate } from 'sveltekit-superforms';\n * import { zod4 } from 'sveltekit-superforms/adapters';\n *\n * const form = await superValidate(request, zod4(loginSchema));\n * ```\n */\n\nexport * from './account';\nexport * from './signup';\nexport * from './login';\nexport * from './password';\nexport * from './profile';\n",
|
|
72
|
+
"/lib/schemas/account.ts": "import { z } from 'zod/v4';\n\nexport const accountSchema = z.object({\n name: z.string().min(2, 'Name must be at least 2 characters').optional(),\n email: z.string().email('Invalid email address'),\n image: z.string().url('Invalid URL').optional(),\n});\n",
|
|
73
|
+
"/lib/schemas/profile.ts": "import { z } from 'zod/v4';\n\n/**\n * Schema for updating user profile\n */\nexport const updateProfileSchema = z.object({\n name: z.string().min(1, { error: 'Name is required' }).max(50, { error: 'Name too long' }).trim(),\n email: z.string().email({ error: 'Invalid email' }).optional(),\n image: z.string().url({ error: 'Invalid URL' }).optional()\n});\n\n/**\n * Inferred types from schemas\n */\nexport type UpdateProfileInput = z.infer<typeof updateProfileSchema>;\n",
|
|
74
|
+
"/lib/schemas/password.test.ts": "import { describe, it, expect } from 'vitest';\nimport { passwordResetSchema, passwordForgotSchema } from '$lib/schemas/password';\n\ndescribe('passwordResetSchema', () => {\n const validData = {\n password: 'newpassword123',\n confirmPassword: 'newpassword123'\n };\n\n it('validates correct data', () => {\n const result = passwordResetSchema.safeParse(validData);\n expect(result.success).toBe(true);\n });\n\n it('rejects short password', () => {\n const result = passwordResetSchema.safeParse({ ...validData, password: 'short', confirmPassword: 'short' });\n expect(result.success).toBe(false);\n });\n\n it('rejects mismatched passwords', () => {\n const result = passwordResetSchema.safeParse({ ...validData, confirmPassword: 'different123' });\n expect(result.success).toBe(false);\n if (!result.success) {\n const confirmError = result.error.issues.find((i) => i.path.includes('confirmPassword'));\n expect(confirmError).toBeDefined();\n expect(confirmError!.message).toBe('Passwords do not match');\n }\n });\n});\n\ndescribe('passwordForgotSchema', () => {\n it('validates correct email', () => {\n const result = passwordForgotSchema.safeParse({ email: 'user@example.com' });\n expect(result.success).toBe(true);\n });\n\n it('rejects invalid email', () => {\n const result = passwordForgotSchema.safeParse({ email: 'not-an-email' });\n expect(result.success).toBe(false);\n });\n\n it('rejects missing email', () => {\n const result = passwordForgotSchema.safeParse({});\n expect(result.success).toBe(false);\n });\n});\n",
|
|
75
|
+
"/lib/schemas/signup.ts": "import { z } from 'zod/v4';\n\nexport const signupSchema = z\n .object({\n name: z.string().min(2, 'Name must be at least 2 characters').optional(),\n email: z.string().email('Invalid email address'),\n password: z.string().min(8, 'Password must be at least 8 characters'),\n confirmPassword: z.string().min(8, 'Password must be at least 8 characters'),\n })\n .refine((data) => data.password === data.confirmPassword, {\n message: 'Passwords do not match',\n path: ['confirmPassword'],\n });\n",
|
|
76
|
+
"/lib/schemas/login.test.ts": "import { describe, it, expect } from 'vitest';\nimport { loginSchema } from '$lib/schemas/login';\n\ndescribe('loginSchema', () => {\n const validData = {\n email: 'user@example.com',\n password: 'password123'\n };\n\n it('validates correct data', () => {\n const result = loginSchema.safeParse(validData);\n expect(result.success).toBe(true);\n });\n\n it('rejects missing email', () => {\n const { email, ...noEmail } = validData;\n const result = loginSchema.safeParse(noEmail);\n expect(result.success).toBe(false);\n });\n\n it('rejects invalid email', () => {\n const result = loginSchema.safeParse({ ...validData, email: 'bad-email' });\n expect(result.success).toBe(false);\n });\n\n it('rejects missing password', () => {\n const { password, ...noPass } = validData;\n const result = loginSchema.safeParse(noPass);\n expect(result.success).toBe(false);\n });\n\n it('rejects short password (< 8 chars)', () => {\n const result = loginSchema.safeParse({ ...validData, password: 'short' });\n expect(result.success).toBe(false);\n });\n\n it('rejects empty object', () => {\n const result = loginSchema.safeParse({});\n expect(result.success).toBe(false);\n });\n});\n",
|
|
77
|
+
"/lib/index.ts": "// place files you want to import through the `$lib` alias in this folder.\n",
|
|
78
|
+
"/lib/server/auth.ts": "// Stub for $lib/server/auth — provided by sv at scaffold time.\n// This stub exists so that vitest can resolve the import in tests.\n// Tests mock this module with vi.mock('$lib/server/auth', ...).\nexport const auth = {\n api: {\n signOut: async () => {}\n }\n};\n",
|
|
79
|
+
"/lib/errors.ts": "/**\n * Standardized error types for SvelteForge application\n */\n\nexport enum ErrorType {\n VALIDATION = 'VALIDATION_ERROR',\n AUTHENTICATION = 'AUTHENTICATION_ERROR',\n AUTHORIZATION = 'AUTHORIZATION_ERROR',\n NOT_FOUND = 'NOT_FOUND_ERROR',\n CONFLICT = 'CONFLICT_ERROR',\n INTERNAL = 'INTERNAL_SERVER_ERROR'\n}\n\nexport class AppError extends Error {\n constructor(\n public type: ErrorType,\n message: string,\n public statusCode: number = 500,\n public details?: Record<string, unknown>\n ) {\n super(message);\n this.name = 'AppError';\n }\n}\n\n// Specific error types\nexport class ValidationError extends AppError {\n constructor(message: string, details?: Record<string, unknown>) {\n super(ErrorType.VALIDATION, message, 400, details);\n }\n}\n\nexport class AuthenticationError extends AppError {\n constructor(message: string = 'Not authenticated') {\n super(ErrorType.AUTHENTICATION, message, 401);\n }\n}\n\nexport class AuthorizationError extends AppError {\n constructor(message: string = 'Not authorized') {\n super(ErrorType.AUTHORIZATION, message, 403);\n }\n}\n\nexport class NotFoundError extends AppError {\n constructor(message: string = 'Resource not found') {\n super(ErrorType.NOT_FOUND, message, 404);\n }\n}\n\nexport class ConflictError extends AppError {\n constructor(message: string = 'Resource already exists') {\n super(ErrorType.CONFLICT, message, 409);\n }\n}\n\nexport class InternalError extends AppError {\n constructor(\n message: string = 'Internal server error',\n details?: Record<string, unknown> | unknown\n ) {\n // Wrap unknown details in an object\n const normalizedDetails =\n details && typeof details === 'object' && !Array.isArray(details)\n ? (details as Record<string, unknown>)\n : { originalError: details };\n super(ErrorType.INTERNAL, message, 500, normalizedDetails);\n }\n}\n",
|
|
80
|
+
"/lib/stores/notification-store.svelte.ts": "/**\n * Notification Store — Svelte 5 rune-based\n *\n * Manages in-app notifications with mock data.\n * Will be replaced by real API calls later.\n */\n\nexport interface Notification {\n id: string;\n title: string;\n message: string;\n target: 'all' | 'admins' | 'user';\n targetUserId?: string;\n createdAt: Date;\n read: boolean;\n}\n\n// --- Admin notification type (for the admin page table) ---\nexport interface AdminNotification {\n id: string;\n title: string;\n message: string;\n target: 'all' | 'admins' | 'user';\n targetUserId?: string;\n createdAt: Date;\n status: 'sent' | 'read';\n}\n\nlet notifications = $state<Notification[]>([]);\nlet adminNotifications = $state<AdminNotification[]>([]);\n\n// --- Derived ---\nlet unreadCount = $derived(notifications.filter((n) => !n.read).length);\n\n// --- Mock data ---\nconst mockNotifications: Notification[] = [\n {\n id: '1',\n title: 'Welcome to SvelteForge',\n message: 'Your account has been set up successfully. Explore the dashboard to get started.',\n target: 'all',\n createdAt: new Date('2026-05-07T20:00:00'),\n read: false\n },\n {\n id: '2',\n title: 'New feature available',\n message: 'Check out the new admin dashboard with improved analytics.',\n target: 'all',\n createdAt: new Date('2026-05-06T14:30:00'),\n read: true\n },\n {\n id: '3',\n title: 'Maintenance scheduled',\n message: 'System maintenance on May 10th at 2AM UTC. Expect brief downtime.',\n target: 'admins',\n createdAt: new Date('2026-05-05T09:00:00'),\n read: false\n },\n {\n id: '4',\n title: 'Profile tips',\n message: 'Complete your profile to unlock all features and improve your experience.',\n target: 'all',\n createdAt: new Date('2026-05-04T16:45:00'),\n read: true\n },\n {\n id: '5',\n title: 'Security update',\n message: 'We have strengthened password requirements. Please review your settings.',\n target: 'all',\n createdAt: new Date('2026-05-03T11:20:00'),\n read: false\n },\n {\n id: '6',\n title: 'Weekly report ready',\n message: 'Your weekly activity summary is now available in the dashboard.',\n target: 'all',\n createdAt: new Date('2026-05-02T08:00:00'),\n read: true\n },\n {\n id: '7',\n title: 'Admin: New user registrations',\n message: '15 new users signed up this week. Review their profiles in the Users panel.',\n target: 'admins',\n createdAt: new Date('2026-05-01T13:00:00'),\n read: false\n }\n];\n\nconst mockAdminNotifications: AdminNotification[] = [\n {\n id: 'a1',\n title: 'Welcome to SvelteForge',\n message: 'Your account has been set up successfully.',\n target: 'all',\n createdAt: new Date('2026-05-07T20:00:00'),\n status: 'sent'\n },\n {\n id: 'a2',\n title: 'New feature available',\n message: 'Check out the new admin dashboard.',\n target: 'all',\n createdAt: new Date('2026-05-06T14:30:00'),\n status: 'read'\n },\n {\n id: 'a3',\n title: 'Maintenance scheduled',\n message: 'System maintenance on May 10th at 2AM UTC.',\n target: 'admins',\n createdAt: new Date('2026-05-05T09:00:00'),\n status: 'sent'\n }\n];\n\nlet initialized = false;\n\n// --- Functions ---\n\nexport function getNotifications(): Notification[] {\n return notifications;\n}\n\nexport function getUnreadCount(): number {\n return unreadCount;\n}\n\nexport function getAdminNotifications(): AdminNotification[] {\n return adminNotifications;\n}\n\nexport function fetchNotifications(): void {\n if (!initialized) {\n notifications = [...mockNotifications];\n adminNotifications = [...mockAdminNotifications];\n initialized = true;\n }\n}\n\nexport function markAsRead(id: string): void {\n notifications = notifications.map((n) =>\n n.id === id ? { ...n, read: true } : n\n );\n}\n\nexport function markAllRead(): void {\n notifications = notifications.map((n) => ({ ...n, read: true }));\n}\n\nexport function dismissNotification(id: string): void {\n notifications = notifications.filter((n) => n.id !== id);\n}\n\nexport function createAdminNotification(opts: {\n title: string;\n message: string;\n target: 'all' | 'admins' | 'user';\n targetUserId?: string;\n}): AdminNotification {\n const newNotif: AdminNotification = {\n id: crypto.randomUUID(),\n title: opts.title,\n message: opts.message,\n target: opts.target,\n targetUserId: opts.targetUserId,\n createdAt: new Date(),\n status: 'sent'\n };\n adminNotifications = [newNotif, ...adminNotifications];\n\n // Also push to user notifications (so the bell sees it)\n const userNotif: Notification = {\n id: newNotif.id,\n title: opts.title,\n message: opts.message,\n target: opts.target,\n targetUserId: opts.targetUserId,\n createdAt: new Date(),\n read: false\n };\n notifications = [userNotif, ...notifications];\n\n return newNotif;\n}\n\n/**\n * Returns a human-readable \"time ago\" string.\n */\nexport function timeAgo(date: Date): string {\n const now = new Date();\n const diffMs = now.getTime() - date.getTime();\n const diffSec = Math.floor(diffMs / 1000);\n const diffMin = Math.floor(diffSec / 60);\n const diffHr = Math.floor(diffMin / 60);\n const diffDay = Math.floor(diffHr / 24);\n\n if (diffSec < 60) return 'just now';\n if (diffMin < 60) return `${diffMin}m ago`;\n if (diffHr < 24) return `${diffHr}h ago`;\n if (diffDay < 7) return `${diffDay}d ago`;\n return date.toLocaleDateString('en-US', {\n month: 'short',\n day: 'numeric',\n year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined\n });\n}\n",
|
|
81
|
+
"/lib/stores/notification-store.svelte.test.ts": "/**\n * Notification Store Tests\n *\n * NOTE: This module uses Svelte 5 runes ($state, $derived) which are\n * compile-time transforms. Vitest with jsdom may not handle these correctly\n * without the Svelte compiler. These tests will be skipped if the module\n * cannot be imported.\n */\nimport { describe, it, expect, beforeEach } from 'vitest';\n\n// Dynamic import to catch rune compilation issues\nconst storeModule = await import('$lib/stores/notification-store.svelte').catch(() => null);\n\ndescribe.skipIf(!storeModule)('notification-store', () => {\n const {\n getNotifications,\n getUnreadCount,\n getAdminNotifications,\n fetchNotifications,\n markAsRead,\n markAllRead,\n dismissNotification,\n createAdminNotification,\n timeAgo\n } = storeModule!;\n\n beforeEach(() => {\n // Re-initialize by calling fetchNotifications\n fetchNotifications();\n });\n\n it('loads mock data on first fetchNotifications', () => {\n const notifs = getNotifications();\n expect(notifs.length).toBeGreaterThan(0);\n });\n\n it('unreadCount matches unread notifications', () => {\n const count = getUnreadCount();\n const notifs = getNotifications();\n const expectedCount = notifs.filter((n) => !n.read).length;\n expect(count).toBe(expectedCount);\n });\n\n it('markAsRead decrements unread count', () => {\n const before = getUnreadCount();\n const unread = getNotifications().find((n) => !n.read);\n if (!unread) return; // skip if no unread\n markAsRead(unread.id);\n expect(getUnreadCount()).toBe(before - 1);\n });\n\n it('markAllRead sets all notifications to read', () => {\n markAllRead();\n expect(getUnreadCount()).toBe(0);\n expect(getNotifications().every((n) => n.read)).toBe(true);\n });\n\n it('dismissNotification removes a notification', () => {\n const before = getNotifications().length;\n const first = getNotifications()[0];\n if (!first) return;\n dismissNotification(first.id);\n expect(getNotifications().length).toBe(before - 1);\n expect(getNotifications().find((n) => n.id === first.id)).toBeUndefined();\n });\n\n it('createAdminNotification adds to admin list', () => {\n const before = getAdminNotifications().length;\n createAdminNotification({\n title: 'Test notification',\n message: 'Test message',\n target: 'all'\n });\n expect(getAdminNotifications().length).toBe(before + 1);\n });\n\n it('createAdminNotification also adds to user notifications', () => {\n const before = getNotifications().length;\n createAdminNotification({\n title: 'Test',\n message: 'Test msg',\n target: 'admins'\n });\n expect(getNotifications().length).toBe(before + 1);\n });\n\n describe('timeAgo', () => {\n it('returns \"just now\" for very recent dates', () => {\n expect(timeAgo(new Date())).toBe('just now');\n });\n\n it('returns minutes for dates a few minutes ago', () => {\n const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000);\n expect(timeAgo(fiveMinAgo)).toBe('5m ago');\n });\n\n it('returns hours for dates a few hours ago', () => {\n const threeHrAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);\n expect(timeAgo(threeHrAgo)).toBe('3h ago');\n });\n\n it('returns days for dates a few days ago', () => {\n const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);\n expect(timeAgo(twoDaysAgo)).toBe('2d ago');\n });\n });\n});\n",
|
|
82
|
+
"/lib/styles/svelteForge.css": ".subtitle {\n font-family: 'Manrope Variable', sans-serif !important;\n font-weight: 500 !important;\n}\n\n.title {\n font-family: 'Space Grotesk Variable', sans-serif !important;\n}\n\n.font-code {\n font-family: 'Fira Code Variable', monospace !important;\n}\n\n[data-theme='svelteForge'] {\n --text-scaling: 1.067;\n --base-font-color: var(--color-surface-950);\n --base-font-color-dark: var(--color-surface-50);\n --base-font-family: 'Inter Variable', sans-serif;\n --base-font-size: inherit;\n --base-line-height: inherit;\n --base-font-weight: normal;\n --base-font-style: normal;\n --base-letter-spacing: 0em;\n --heading-font-color: inherit;\n --heading-font-color-dark: inherit;\n --heading-font-family: 'Space Grotesk Variable', sans-serif;\n --heading-font-weight: bold;\n --heading-font-style: normal;\n --heading-letter-spacing: inherit;\n --anchor-font-color: var(--color-primary-700);\n --anchor-font-color-dark: var(--color-primary-200); /* Ajusté pour une meilleure lisibilité */\n --anchor-font-family: inherit;\n --anchor-font-size: inherit;\n --anchor-line-height: inherit;\n --anchor-font-weight: inherit;\n --anchor-font-style: inherit;\n --anchor-letter-spacing: inherit;\n --anchor-text-decoration: none;\n --anchor-text-decoration-hover: underline;\n --anchor-text-decoration-active: none;\n --anchor-text-decoration-focus: none;\n --spacing: 0.25rem;\n --radius-base: 0.25rem;\n --radius-container: 0.25rem;\n --default-border-width: 1px;\n --default-divide-width: 1px;\n --default-ring-width: 1px;\n --default-border-color: var(--color-surface-300);\n --default-border-color-dark: var(--color-surface-600); /* Bordures plus visibles en dark mode */\n --body-background-color: var(--color-surface-50);\n --body-background-color-dark: var(--color-surface-950);\n\n /* Couleurs primaires : Bleu acier (inchangées, déjà optimales) */\n --color-primary-50: oklch(94.82% 0.04 216.84deg);\n --color-primary-100: oklch(88.84% 0.07 215.45deg);\n --color-primary-200: oklch(83.12% 0.09 215.47deg);\n --color-primary-300: oklch(78.03% 0.11 216.31deg);\n --color-primary-400: oklch(73.14% 0.12 218.53deg);\n --color-primary-500: oklch(68.74% 0.13 222.38deg);\n --color-primary-600: oklch(63.96% 0.12 222.67deg);\n --color-primary-700: oklch(59.05% 0.11 222.32deg);\n --color-primary-800: oklch(53.82% 0.1 223.63deg);\n --color-primary-900: oklch(48.67% 0.09 223.32deg);\n --color-primary-950: oklch(43.44% 0.08 223.92deg);\n --color-primary-contrast-dark: oklch(20.02% 0 none);\n --color-primary-contrast-light: oklch(97.91% 0 none);\n --color-primary-contrast-50: var(--color-primary-contrast-dark);\n --color-primary-contrast-100: var(--color-primary-contrast-dark);\n --color-primary-contrast-200: var(--color-primary-contrast-dark);\n --color-primary-contrast-300: var(--color-primary-contrast-dark);\n --color-primary-contrast-400: var(--color-primary-contrast-dark);\n --color-primary-contrast-500: var(--color-primary-contrast-dark);\n --color-primary-contrast-600: var(--color-primary-contrast-dark);\n --color-primary-contrast-700: var(--color-primary-contrast-dark);\n --color-primary-contrast-800: var(--color-primary-contrast-light);\n --color-primary-contrast-900: var(--color-primary-contrast-light);\n --color-primary-contrast-950: var(--color-primary-contrast-light);\n\n /* Couleurs secondaires : Rouge orangé (désaturé pour un rendu plus professionnel) */\n --color-secondary-50: oklch(93.05% 0.05 71.66deg);\n --color-secondary-100: oklch(86.31% 0.07 50.93deg);\n --color-secondary-200: oklch(79.78% 0.1 39.88deg);\n --color-secondary-300: oklch(73.56% 0.12 32.86deg); /* Légèrement désaturé */\n --color-secondary-400: oklch(67.93% 0.14 30.33deg); /* Désaturé */\n --color-secondary-500: oklch(63.07% 0.17 29.44deg); /* Désaturé */\n --color-secondary-600: oklch(58.47% 0.15 27.5deg); /* Désaturé */\n --color-secondary-700: oklch(53.83% 0.13 24.55deg); /* Désaturé */\n --color-secondary-800: oklch(49.31% 0.11 20.41deg); /* Désaturé */\n --color-secondary-900: oklch(44.67% 0.09 12.99deg); /* Désaturé */\n --color-secondary-950: oklch(40.1% 0.07 0.14deg);\n --color-secondary-contrast-dark: oklch(97.91% 0 none);\n --color-secondary-contrast-light: oklch(20.02% 0 none);\n\n --color-secondary-contrast-50: var(--color-secondary-contrast-light);\n\n --color-secondary-contrast-100: var(--color-secondary-contrast-light);\n\n --color-secondary-contrast-200: var(--color-secondary-contrast-light);\n\n --color-secondary-contrast-300: var(--color-secondary-contrast-light);\n\n --color-secondary-contrast-400: var(--color-secondary-contrast-light);\n --color-secondary-contrast-500: var(--color-secondary-contrast-light);\n --color-secondary-contrast-600: var(--color-secondary-contrast-dark);\n --color-secondary-contrast-700: var(--color-secondary-contrast-dark);\n --color-secondary-contrast-800: var(--color-secondary-contrast-dark);\n --color-secondary-contrast-900: var(--color-secondary-contrast-dark);\n --color-secondary-contrast-950: var(--color-secondary-contrast-dark);\n\n /* Couleurs tertiaires : Simplifiées (optionnel : remplace par des dégradés de surface si inutilisées) */\n --color-tertiary-50: oklch(98.31% 0.02 196.74deg);\n --color-tertiary-100: oklch(90.4% 0.03 193.59deg);\n --color-tertiary-200: oklch(82.32% 0.03 191.41deg);\n --color-tertiary-300: oklch(74.29% 0.04 187.78deg);\n --color-tertiary-400: oklch(65.86% 0.04 186.58deg);\n --color-tertiary-500: oklch(57.22% 0.05 185.36deg);\n --color-tertiary-600: oklch(52.72% 0.05 190.1deg);\n --color-tertiary-700: oklch(48.14% 0.04 195.76deg);\n --color-tertiary-800: oklch(43.13% 0.04 204.75deg);\n --color-tertiary-900: oklch(38.31% 0.04 212.77deg);\n --color-tertiary-950: oklch(33.36% 0.04 221.8deg);\n --color-tertiary-contrast-dark: var(--color-tertiary-950);\n --color-tertiary-contrast-light: var(--color-tertiary-50);\n --color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);\n --color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);\n --color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);\n --color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);\n --color-tertiary-contrast-400: var(--color-tertiary-contrast-dark);\n --color-tertiary-contrast-500: var(--color-tertiary-contrast-light);\n --color-tertiary-contrast-600: var(--color-tertiary-contrast-light);\n --color-tertiary-contrast-700: var(--color-tertiary-contrast-light);\n --color-tertiary-contrast-800: var(--color-tertiary-contrast-light);\n --color-tertiary-contrast-900: var(--color-tertiary-contrast-light);\n --color-tertiary-contrast-950: var(--color-tertiary-contrast-light);\n\n /* Couleurs de succès (inchangées) */\n --color-success-50: oklch(93.9% 0.09 179.09deg);\n --color-success-100: oklch(91.57% 0.1 178.72deg);\n --color-success-200: oklch(89.48% 0.11 177.05deg);\n --color-success-300: oklch(87.2% 0.12 176.72deg);\n --color-success-400: oklch(85.21% 0.12 175.14deg);\n --color-success-500: oklch(83.08% 0.13 174.56deg);\n --color-success-600: oklch(72.99% 0.11 174.61deg);\n --color-success-700: oklch(62.32% 0.1 176.14deg);\n --color-success-800: oklch(51.37% 0.08 176.77deg);\n --color-success-900: oklch(39.58% 0.06 180.47deg);\n --color-success-950: oklch(27.1% 0.04 184.49deg);\n --color-success-contrast-dark: var(--color-success-950);\n --color-success-contrast-light: var(--color-success-50);\n --color-success-contrast-50: var(--color-success-contrast-dark);\n --color-success-contrast-100: var(--color-success-contrast-dark);\n --color-success-contrast-200: var(--color-success-contrast-dark);\n --color-success-contrast-300: var(--color-success-contrast-dark);\n --color-success-contrast-400: var(--color-success-contrast-dark);\n --color-success-contrast-500: var(--color-success-contrast-dark);\n --color-success-contrast-600: var(--color-success-contrast-dark);\n --color-success-contrast-700: var(--color-success-contrast-dark);\n --color-success-contrast-800: var(--color-success-contrast-light);\n --color-success-contrast-900: var(--color-success-contrast-light);\n --color-success-contrast-950: var(--color-success-contrast-light);\n\n /* Couleurs d'avertissement (inchangées) */\n --color-warning-50: oklch(95.79% 0.05 88.07deg);\n --color-warning-100: oklch(92.94% 0.07 85.27deg);\n --color-warning-200: oklch(90.05% 0.09 81.8deg);\n --color-warning-300: oklch(87.31% 0.11 80.89deg);\n --color-warning-400: oklch(84.57% 0.13 78.29deg);\n --color-warning-500: oklch(82.02% 0.14 76.71deg);\n --color-warning-600: oklch(76.11% 0.14 72.37deg);\n --color-warning-700: oklch(70.14% 0.13 67.8deg);\n --color-warning-800: oklch(64% 0.13 62.98deg);\n --color-warning-900: oklch(57.93% 0.13 57.46deg);\n --color-warning-950: oklch(51.87% 0.13 51.28deg);\n --color-warning-contrast-dark: var(--color-warning-950);\n --color-warning-contrast-light: var(--color-warning-50);\n --color-warning-contrast-50: var(--color-warning-contrast-dark);\n --color-warning-contrast-100: var(--color-warning-contrast-dark);\n --color-warning-contrast-200: var(--color-warning-contrast-dark);\n --color-warning-contrast-300: var(--color-warning-contrast-dark);\n --color-warning-contrast-400: var(--color-warning-contrast-dark);\n --color-warning-contrast-500: var(--color-warning-contrast-dark);\n --color-warning-contrast-600: var(--color-warning-contrast-dark);\n --color-warning-contrast-700: var(--color-warning-contrast-light);\n --color-warning-contrast-800: var(--color-warning-contrast-light);\n --color-warning-contrast-900: var(--color-warning-contrast-light);\n --color-warning-contrast-950: var(--color-warning-contrast-light);\n\n /* Couleurs d'erreur (inchangées) */\n --color-error-50: oklch(89.99% 0.04 14.04deg);\n --color-error-100: oklch(83.6% 0.08 19.82deg);\n --color-error-200: oklch(77.52% 0.11 21.99deg);\n --color-error-300: oklch(72.26% 0.15 24.9deg);\n --color-error-400: oklch(67.55% 0.19 26.72deg);\n --color-error-500: oklch(64.06% 0.22 28.71deg);\n --color-error-600: oklch(59.71% 0.21 28.7deg);\n --color-error-700: oklch(55.17% 0.2 28.73deg);\n --color-error-800: oklch(50.88% 0.19 28.77deg);\n --color-error-900: oklch(46.35% 0.18 28.89deg);\n --color-error-950: oklch(42.03% 0.17 29.27deg);\n --color-error-contrast-dark: var(--color-error-950);\n --color-error-contrast-light: var(--color-error-50);\n --color-error-contrast-50: var(--color-error-contrast-dark);\n --color-error-contrast-100: var(--color-error-contrast-dark);\n --color-error-contrast-200: var(--color-error-contrast-dark);\n --color-error-contrast-300: var(--color-error-contrast-dark);\n --color-error-contrast-400: var(--color-error-contrast-dark);\n --color-error-contrast-500: var(--color-error-contrast-light);\n --color-error-contrast-600: var(--color-error-contrast-light);\n --color-error-contrast-700: var(--color-error-contrast-light);\n --color-error-contrast-800: var(--color-error-contrast-light);\n --color-error-contrast-900: var(--color-error-contrast-light);\n --color-error-contrast-950: var(--color-error-contrast-light);\n\n /* Couleurs de surface : Légèrement teintées pour plus de profondeur */\n --color-surface-50: oklch(96% 0.01 260deg); /* Teinte bleutée subtile */\n --color-surface-100: oklch(90% 0.01 260deg);\n --color-surface-200: oklch(78.47% 0.02 264.48deg);\n --color-surface-300: oklch(68.53% 0.03 265.52deg);\n --color-surface-400: oklch(58.21% 0.04 265.04deg);\n --color-surface-500: oklch(47.47% 0.05 264.53deg);\n --color-surface-600: oklch(42.05% 0.05 264.5deg);\n --color-surface-700: oklch(36.45% 0.05 264.44deg);\n --color-surface-800: oklch(30.96% 0.04 263.65deg);\n --color-surface-900: oklch(24.88% 0.04 263.42deg);\n --color-surface-950: oklch(18.45% 0.04 262.95deg);\n --color-surface-contrast-dark: var(--color-surface-950);\n --color-surface-contrast-light: var(--color-surface-50);\n --color-surface-contrast-50: var(--color-surface-contrast-dark);\n --color-surface-contrast-100: var(--color-surface-contrast-dark);\n --color-surface-contrast-200: var(--color-surface-contrast-dark);\n --color-surface-contrast-300: var(--color-surface-contrast-dark);\n --color-surface-contrast-400: var(--color-surface-contrast-dark);\n --color-surface-contrast-500: var(--color-surface-contrast-light);\n --color-surface-contrast-600: var(--color-surface-contrast-light);\n --color-surface-contrast-700: var(--color-surface-contrast-light);\n --color-surface-contrast-800: var(--color-surface-contrast-light);\n --color-surface-contrast-900: var(--color-surface-contrast-light);\n --color-surface-contrast-950: var(--color-surface-contrast-light);\n}\n",
|
|
83
|
+
"/lib/styles/tokens.css": "/* ============================================================================\n SvelteForge Design Tokens\n\n Semantic tokens for spacing, sizing, radius, and typography.\n Override these per theme to change the look without touching components.\n\n Used alongside Skeleton UI theme tokens (colors) in svelteForge.css.\n ============================================================================ */\n\n[data-theme='svelteForge'] {\n /* =========================================================================\n PADDING\n ========================================================================= */\n\n /* Card */\n --card-p: 1rem;\n --card-p-lg: 1.5rem;\n --card-header-px: 1rem;\n --card-header-px-lg: 1.5rem;\n --card-header-py: 0.75rem;\n --card-header-py-lg: 1rem;\n --card-icon-p: 0.375rem;\n\n /* AuthCard */\n --authcard-p: 1.5rem;\n --authcard-p-lg: 2.5rem;\n\n /* Modal */\n --modal-header-px: 1.5rem;\n --modal-header-py: 1rem;\n --modal-body-p: 1.5rem;\n\n /* Dialog */\n --dialog-p: 1.5rem;\n\n /* Alert */\n --alert-p: 1rem;\n\n /* Toast */\n --toast-p: 1rem;\n\n /* Menu */\n --menu-panel-p: 0.25rem;\n --menu-item-px: 0.75rem;\n --menu-item-py: 0.5rem;\n\n /* Tabs */\n --tab-trigger-px: 0.75rem;\n --tab-trigger-py: 0.5rem;\n --tab-pills-p: 0.25rem;\n\n /* Table */\n --table-cell-px: 1rem;\n --table-cell-py: 0.75rem;\n --table-empty-py: 2rem;\n\n /* Input (custom, e.g. PasswordInput) */\n --input-custom-px: 0.75rem;\n --input-custom-py: 0.5rem;\n\n /* Submit button */\n --submit-py: 0.75rem;\n\n /* Footer */\n --footer-px: 1rem;\n --footer-px-md: 1.5rem;\n --footer-px-lg: 2rem;\n --footer-py: 2rem;\n --footer-py-md: 3rem;\n\n /* =========================================================================\n RADIUS\n ========================================================================= */\n\n --radius-card: 0.75rem;\n --radius-authcard: 1.5rem;\n --radius-alert: 0.75rem;\n --radius-menu-item: 0.5rem;\n --radius-tab-pills: 0.5rem;\n --radius-input-custom: 0.5rem;\n --radius-toggle: 0.375rem;\n --radius-icon-wrap: 0.5rem;\n\n /* =========================================================================\n FONT SIZE\n ========================================================================= */\n\n --text-caption: 0.75rem;\n --text-body: 0.875rem;\n --text-label: 0.875rem;\n --text-card-title: 1rem;\n --text-card-title-lg: 1.125rem;\n --text-modal-title: 1.125rem;\n --text-dialog-title: 1.25rem;\n --text-auth-title: 1.875rem;\n --text-auth-title-lg: 2.25rem;\n --text-logo: 1.25rem;\n --text-submit: 1rem;\n\n /* =========================================================================\n FONT WEIGHT\n ========================================================================= */\n\n --weight-label: 500;\n --weight-subtitle: 600;\n --weight-title: 700;\n\n /* =========================================================================\n MAX WIDTH\n ========================================================================= */\n\n --max-w-authcard: 28rem;\n --max-w-modal-sm: 24rem;\n --max-w-modal-md: 28rem;\n --max-w-modal-lg: 32rem;\n --max-w-modal-xl: 36rem;\n --max-w-dialog: 28rem;\n --max-w-toast: 20rem;\n --max-w-footer: 80rem;\n\n /* =========================================================================\n GAP / SPACING\n ========================================================================= */\n\n --gap-xs: 0.25rem;\n --gap-sm: 0.5rem;\n --gap-md: 0.75rem;\n --gap-lg: 1rem;\n --gap-xl: 2rem;\n\n --space-section: 2rem;\n --space-group: 1.5rem;\n --space-element: 1rem;\n --space-inline: 0.5rem;\n --space-micro: 0.25rem;\n --space-nano: 0.125rem;\n\n /* =========================================================================\n SIZING\n ========================================================================= */\n\n --avatar-sm: 2rem;\n --avatar-md: 2.5rem;\n --avatar-lg: 3rem;\n --avatar-xl: 4rem;\n\n --menu-min-w: 11.25rem;\n --strength-bar-h: 0.375rem;\n\n /* =========================================================================\n TOOLTIP\n ========================================================================= */\n\n --tooltip-bg: var(--color-surface-900);\n --tooltip-color: var(--color-surface-50);\n --tooltip-radius: 0.375rem;\n --tooltip-px: 0.5rem;\n --tooltip-py: 0.25rem;\n --tooltip-font-size: 0.75rem;\n\n /* =========================================================================\n ACCORDION\n ========================================================================= */\n\n --accordion-trigger-px: 1rem;\n --accordion-trigger-py: 0.75rem;\n --accordion-content-px: 1rem;\n --accordion-content-py: 0.75rem;\n\n /* =========================================================================\n PROGRESS\n ========================================================================= */\n\n --progress-sm: 0.25rem;\n --progress-md: 0.5rem;\n --progress-lg: 0.75rem;\n\n /* =========================================================================\n DIVIDER\n ========================================================================= */\n\n --divider-color: var(--color-surface-300);\n --divider-spacing: var(--space-lg);\n}\n",
|
|
84
|
+
"/lib/styles/fonts.css": "@import '@fontsource-variable/inter';\n@import '@fontsource-variable/space-grotesk';\n@import '@fontsource-variable/manrope';\n@import '@fontsource-variable/fira-code';\n",
|
|
85
|
+
"/lib/logger.ts": "import pino from 'pino';\nimport type { RequestEvent } from '@sveltejs/kit';\n\n// ============================================================================\n// ENVIRONMENT DETECTION\n// ============================================================================\n\nconst browser = typeof window !== 'undefined';\nconst isDev = import.meta.env.DEV;\nconst isTest = import.meta.env.MODE === 'test';\nconst isProd = import.meta.env.PROD;\n\n// ============================================================================\n// TYPE DEFINITIONS\n// ============================================================================\n\ninterface LoggerContext {\n [key: string]: unknown;\n}\n\ninterface BaseLogger {\n info: (obj: LoggerContext, msg?: string) => void;\n warn: (obj: LoggerContext, msg?: string) => void;\n error: (obj: LoggerContext, msg?: string) => void;\n debug: (obj: LoggerContext, msg?: string) => void;\n child: (bindings: LoggerContext) => BaseLogger;\n}\n\n// ============================================================================\n// BROWSER LOGGER (DEV ONLY - NO-OP IN PROD)\n// ============================================================================\n\nconst createBrowserLogger = (): BaseLogger => {\n // In production browser, return a no-op logger for performance\n if (isProd) {\n return {\n info: () => {},\n warn: () => {},\n error: () => {},\n debug: () => {},\n child: () => createBrowserLogger()\n };\n }\n\n // In dev browser, use console with prefixes\n return {\n info: (obj, msg) => console.log('[INFO]', msg || '', obj),\n warn: (obj, msg) => console.warn('[WARN]', msg || '', obj),\n error: (obj, msg) => console.error('[ERROR]', msg || '', obj),\n debug: (obj, msg) => console.debug('[DEBUG]', msg || '', obj),\n child: () => createBrowserLogger()\n };\n};\n\n// ============================================================================\n// SERVER LOGGER (PINO)\n// ============================================================================\n\nconst pinoConfig: pino.LoggerOptions = {\n level: import.meta.env.LOG_LEVEL || (isDev ? 'debug' : 'info'),\n // Redact sensitive data\n redact: {\n paths: ['req.headers.authorization', 'req.headers.cookie', 'password', 'token', 'apiKey'],\n remove: true\n }\n};\n\n// Note: pino.transport() is not compatible with Vite's SSR (causes \"option.transport do not allow stream\" error)\n// For pretty logs in dev, run: bun dev | pino-pretty\n// Or use the DEBUG_LOG environment variable to enable JSON logs\n\n// ============================================================================\n// LOGGER INSTANCE\n// ============================================================================\n\nexport const logger = (browser ? createBrowserLogger() : pino(pinoConfig)) as BaseLogger;\n\n// ============================================================================\n// HELPER FUNCTIONS\n// ============================================================================\n\n/**\n * Create a child logger with additional context\n */\nexport function createChildLogger(context: LoggerContext): BaseLogger {\n return logger.child(context);\n}\n\n/**\n * Log HTTP request with timing\n */\nexport function logRequest(event: RequestEvent, duration: number): void {\n const { request } = event;\n logger.info(\n {\n method: request.method,\n url: request.url,\n userAgent: request.headers.get('user-agent'),\n duration,\n status: event.locals.responseStatus || 'unknown'\n },\n 'Request completed'\n );\n}\n\n/**\n * Log error with context\n */\nexport function logError(error: Error, context?: LoggerContext): void {\n logger.error(\n {\n error: {\n name: error.name,\n message: error.message,\n stack: error.stack\n },\n ...context\n },\n 'Error occurred'\n );\n}\n",
|
|
86
|
+
"/lib/errors.test.ts": "import { describe, it, expect } from 'vitest';\nimport {\n AppError,\n ErrorType,\n ValidationError,\n AuthenticationError,\n AuthorizationError,\n NotFoundError,\n ConflictError,\n InternalError\n} from '$lib/errors';\n\ndescribe('AppError', () => {\n it('creates an error with all properties', () => {\n const err = new AppError(ErrorType.VALIDATION, 'test', 400, { field: 'email' });\n expect(err.message).toBe('test');\n expect(err.type).toBe(ErrorType.VALIDATION);\n expect(err.statusCode).toBe(400);\n expect(err.details).toEqual({ field: 'email' });\n expect(err.name).toBe('AppError');\n expect(err).toBeInstanceOf(Error);\n });\n});\n\ndescribe('Specific error types', () => {\n it('ValidationError defaults to 400', () => {\n const err = new ValidationError('Invalid input', { field: 'name' });\n expect(err.statusCode).toBe(400);\n expect(err.type).toBe(ErrorType.VALIDATION);\n });\n\n it('AuthenticationError defaults to 401', () => {\n const err = new AuthenticationError();\n expect(err.statusCode).toBe(401);\n expect(err.message).toBe('Not authenticated');\n });\n\n it('AuthorizationError defaults to 403', () => {\n const err = new AuthorizationError('Forbidden');\n expect(err.statusCode).toBe(403);\n });\n\n it('NotFoundError defaults to 404', () => {\n const err = new NotFoundError();\n expect(err.statusCode).toBe(404);\n expect(err.message).toBe('Resource not found');\n });\n\n it('ConflictError defaults to 409', () => {\n const err = new ConflictError();\n expect(err.statusCode).toBe(409);\n });\n\n it('InternalError normalizes unknown details', () => {\n const err = new InternalError('oops', 'string-details');\n expect(err.statusCode).toBe(500);\n expect(err.details).toEqual({ originalError: 'string-details' });\n });\n\n it('InternalError keeps object details', () => {\n const err = new InternalError('oops', { code: 'DB_FAIL' });\n expect(err.details).toEqual({ code: 'DB_FAIL' });\n });\n});\n",
|
|
87
|
+
"/lib/components/index.ts": "// Layout components\nexport * from './layout';\n\n// UI components\nexport * from './ui';\n",
|
|
88
|
+
"/lib/components/ui/ThemeToggle.svelte": "<script lang=\"ts\">\n import { onMount, onDestroy } from 'svelte';\n import { themeStore } from '$lib/utils/theme.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n let mounted = $state(false);\n\n onMount(() => {\n themeStore.init();\n mounted = true;\n });\n\n onDestroy(() => {\n themeStore.destroy();\n });\n<\/script>\n\n{#if mounted}\n <button\n type=\"button\"\n onclick={() => themeStore.toggle()}\n class=\"btn-icon hover:bg-surface-200-800 text-surface-900-50 transition-colors\"\n aria-label={themeStore.isDark ? 'Switch to light mode' : 'Switch to dark mode'}\n title={themeStore.isDark ? 'Switch to light mode' : 'Switch to dark mode'}\n >\n {#if themeStore.isDark}\n <Icon name=\"moon\" size={20} />\n {:else}\n <Icon name=\"sun\" size={20} />\n {/if}\n </button>\n{/if}\n",
|
|
89
|
+
"/lib/components/ui/SuccessAlert.svelte": "<script lang=\"ts\">\n import Icon from '$lib/components/icons/Icon.svelte';\n\n interface Props {\n title?: string;\n message: string;\n class?: string;\n }\n\n let { title = 'Success', message, class: className = '' }: Props = $props();\n<\/script>\n\n<div class=\"preset-tonal-success-500 flex items-start {className}\" style=\"padding: var(--alert-p); border-radius: var(--radius-alert); gap: var(--gap-md)\" role=\"status\">\n <Icon name=\"checkCircle\" size={20} class=\"shrink-0\" style=\"margin-top: var(--space-nano)\" />\n <div>\n <p style=\"font-weight: var(--weight-subtitle)\">{title}</p>\n <p style=\"font-size: var(--text-body); margin-top: var(--space-nano)\">{message}</p>\n </div>\n</div>\n",
|
|
90
|
+
"/lib/components/ui/NavigationLoader.svelte": "<script lang=\"ts\">\n import { navigating } from '$app/stores';\n<\/script>\n\n{#if $navigating}\n <div class=\"fixed top-0 left-0 right-0 z-[100] h-1\">\n <div class=\"h-full bg-primary-500 animate-pulse\"></div>\n </div>\n{/if}\n",
|
|
91
|
+
"/lib/components/ui/utils/cn.ts": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n",
|
|
92
|
+
"/lib/components/ui/utils/cn.test.ts": "import { describe, it, expect } from 'vitest';\nimport { cn } from './cn';\n\ndescribe('cn utility', () => {\n it('merges class strings', () => {\n expect(cn('foo', 'bar')).toBe('foo bar');\n });\n\n it('handles conditional classes (truthy)', () => {\n expect(cn('base', true && 'active', false && 'hidden')).toBe('base active');\n });\n\n it('handles empty and undefined inputs', () => {\n expect(cn()).toBe('');\n expect(cn(undefined)).toBe('');\n expect(cn('')).toBe('');\n });\n\n it('handles mixed values', () => {\n expect(cn('a', undefined, 'b', null, 'c')).toBe('a b c');\n });\n\n it('merges tailwind classes intelligently (dedupes conflicting)', () => {\n // twMerge should resolve conflicting Tailwind classes\n expect(cn('px-2', 'px-4')).toBe('px-4');\n expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');\n });\n\n it('preserves non-conflicting tailwind classes', () => {\n expect(cn('px-2', 'py-4')).toBe('px-2 py-4');\n });\n});\n",
|
|
93
|
+
"/lib/components/ui/Divider.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n\n type DividerOrientation = 'horizontal' | 'vertical';\n\n interface Props {\n /** Direction of the divider line */\n orientation?: DividerOrientation;\n /** Optional text label displayed in the center of the divider (e.g. \"OR\") */\n label?: string;\n /** Additional CSS classes applied to the root element */\n class?: string;\n }\n\n let { orientation = 'horizontal', label, class: className = '' }: Props = $props();\n\n const isHorizontal = $derived(orientation === 'horizontal');\n\n const rootClass = $derived(\n cn(\n 'sf-divider',\n isHorizontal ? 'sf-divider-horizontal' : 'sf-divider-vertical',\n label ? 'sf-divider-with-label' : '',\n className\n )\n );\n<\/script>\n\n<div\n class={rootClass}\n role=\"separator\"\n aria-orientation={orientation}\n>\n {#if label}\n <span class=\"sf-divider-line\" />\n <span class=\"sf-divider-label\">{label}</span>\n <span class=\"sf-divider-line\" />\n {:else}\n <span class=\"sf-divider-line\" />\n {/if}\n</div>\n\n<style>\n /* ── Horizontal ── */\n\n .sf-divider-horizontal {\n display: flex;\n align-items: center;\n gap: var(--gap-md);\n padding: var(--divider-spacing) 0;\n }\n\n .sf-divider-horizontal .sf-divider-line {\n flex: 1;\n height: 1px;\n background-color: var(--divider-color);\n }\n\n /* ── Vertical ── */\n\n .sf-divider-vertical {\n display: flex;\n flex-direction: column;\n align-items: center;\n gap: var(--gap-md);\n padding: 0 var(--divider-spacing);\n align-self: stretch;\n }\n\n .sf-divider-vertical .sf-divider-line {\n flex: 1;\n width: 1px;\n background-color: var(--divider-color);\n }\n\n /* ── Label ── */\n\n .sf-divider-label {\n font-size: var(--text-caption);\n font-weight: var(--weight-label);\n color: var(--color-surface-500);\n white-space: nowrap;\n line-height: 1;\n }\n</style>\n",
|
|
94
|
+
"/lib/components/ui/ErrorAlert.svelte": "<script lang=\"ts\">\n import Icon from '$lib/components/icons/Icon.svelte';\n\n interface Props {\n title?: string;\n message: string;\n class?: string;\n }\n\n let { title = 'Error', message, class: className = '' }: Props = $props();\n<\/script>\n\n<div class=\"preset-tonal-error-500 flex items-start {className}\" style=\"padding: var(--alert-p); border-radius: var(--radius-alert); gap: var(--gap-md)\" role=\"alert\">\n <Icon name=\"alertCircle\" size={20} class=\"shrink-0\" style=\"margin-top: var(--space-nano)\" />\n <div>\n <p style=\"font-weight: var(--weight-subtitle)\">{title}</p>\n <p style=\"font-size: var(--text-body); margin-top: var(--space-nano)\">{message}</p>\n </div>\n</div>\n",
|
|
95
|
+
"/lib/components/ui/RadioGroup.svelte": "<script lang=\"ts\">\n import { SegmentedControl } from '@skeletonlabs/skeleton-svelte';\n\n interface RadioOption {\n value: string;\n label: string;\n }\n\n interface Props {\n options: RadioOption[];\n value?: string;\n name: string;\n label?: string;\n class?: string;\n }\n\n let {\n options,\n value = $bindable(options[0]?.value ?? ''),\n name,\n label,\n class: className = ''\n }: Props = $props();\n<\/script>\n\n<div class=\"radio-group {className}\">\n <SegmentedControl\n {value}\n name={name}\n onValueChange={(details: { value: string }) => {\n value = details.value;\n }}\n >\n {#if label}\n <SegmentedControl.Label>\n <span class=\"radio-group-label\">{label}</span>\n </SegmentedControl.Label>\n {/if}\n\n <SegmentedControl.Control>\n <SegmentedControl.Indicator />\n {#each options as option (option.value)}\n <SegmentedControl.Item value={option.value}>\n <SegmentedControl.ItemText>\n <span class=\"radio-group-text\">{option.label}</span>\n </SegmentedControl.ItemText>\n <SegmentedControl.ItemHiddenInput />\n </SegmentedControl.Item>\n {/each}\n </SegmentedControl.Control>\n </SegmentedControl>\n</div>\n\n<style>\n .radio-group-label {\n font-size: var(--text-body);\n font-weight: var(--weight-label);\n }\n\n .radio-group-text {\n font-size: var(--text-body);\n }\n</style>\n",
|
|
96
|
+
"/lib/components/ui/Tooltip.svelte": "<script lang=\"ts\">\n import { Tooltip, useTooltip } from '@skeletonlabs/skeleton-svelte';\n import { cn } from './utils/cn';\n import type { Snippet } from 'svelte';\n\n type TooltipSide = 'top' | 'right' | 'bottom' | 'left';\n\n interface Props {\n /** The text displayed inside the tooltip */\n content: string;\n /** Preferred placement of the tooltip relative to the trigger */\n side?: TooltipSide;\n /** Delay in milliseconds before the tooltip opens (default 200) */\n delay?: number;\n /** Additional CSS classes applied to the tooltip content panel */\n class?: string;\n /** Snippet rendered as the trigger element */\n children: Snippet;\n }\n\n let {\n content,\n side = 'top',\n delay = 200,\n class: className = '',\n children\n }: Props = $props();\n\n const id = `sf-tooltip-${Math.random().toString(36).slice(2, 9)}`;\n\n const api = useTooltip({\n id,\n positioning: { placement: side },\n openDelay: delay\n });\n\n const contentClass = $derived(cn('sf-tooltip', className));\n<\/script>\n\n<Tooltip.Provider api={api}>\n <Tooltip.Trigger api={api}>\n {@render children()}\n </Tooltip.Trigger>\n <Tooltip.Positioner api={api}>\n <Tooltip.Content api={api} class={contentClass}>\n {content}\n </Tooltip.Content>\n </Tooltip.Positioner>\n</Tooltip.Provider>\n\n<style>\n .sf-tooltip {\n background-color: var(--tooltip-bg);\n color: var(--tooltip-color);\n font-size: var(--tooltip-font-size);\n padding: var(--tooltip-py) var(--tooltip-px);\n border-radius: var(--tooltip-radius);\n line-height: 1.4;\n pointer-events: none;\n max-width: 16rem;\n }\n</style>\n",
|
|
97
|
+
"/lib/components/ui/index.ts": "export { default as Accordion } from './Accordion.svelte';\nexport { default as AuthCard } from './AuthCard.svelte';\nexport { default as Avatar } from './Avatar.svelte';\nexport { default as Badge } from './Badge.svelte';\nexport { default as Breadcrumb } from './Breadcrumb.svelte';\nexport { default as Button } from './Button.svelte';\nexport { default as Card } from './Card.svelte';\nexport { default as Carousel } from './Carousel.svelte';\nexport { default as ConfirmDialog } from './ConfirmDialog.svelte';\nexport { default as DataTable } from './DataTable.svelte';\nexport { default as Divider } from './Divider.svelte';\nexport { default as EmptyState } from './EmptyState.svelte';\nexport { default as ErrorAlert } from './ErrorAlert.svelte';\nexport { default as Loader } from './Loader.svelte';\nexport { default as Menu } from './Menu.svelte';\nexport { default as Modal } from './Modal.svelte';\nexport { default as NavigationLoader } from './NavigationLoader.svelte';\nexport { default as NotificationBadge } from './NotificationBadge.svelte';\nexport { default as PopOver } from './PopOver.svelte';\nexport { default as Progress } from './Progress.svelte';\nexport { default as RadioGroup } from './RadioGroup.svelte';\nexport { default as SearchInput } from './SearchInput.svelte';\nexport { default as Sheet } from './Sheet.svelte';\nexport { default as SkeletonLoader } from './SkeletonLoader.svelte';\nexport { default as Stepper } from './Stepper.svelte';\nexport { default as SuccessAlert } from './SuccessAlert.svelte';\nexport { default as Switch } from './Switch.svelte';\nexport { default as Tabs } from './Tabs.svelte';\nexport { default as ThemeToggle } from './ThemeToggle.svelte';\nexport { default as Toast } from './Toast.svelte';\nexport { addToast, removeToast } from './toast-state.svelte';\nexport { default as Tooltip } from './Tooltip.svelte';\n// === Rich Text ===\nexport * from './rich-text';\n\n// === Form ===\nexport * from './form';\n",
|
|
98
|
+
"/lib/components/ui/Modal.svelte": "<script lang=\"ts\">\n import { Dialog, Portal } from '@skeletonlabs/skeleton-svelte';\n import type { Snippet } from 'svelte';\n\n interface Props {\n open: boolean;\n title: string;\n onClose: () => void;\n size?: 'sm' | 'md' | 'lg' | 'xl';\n header?: Snippet;\n children: Snippet;\n footer?: Snippet;\n }\n\n let { open, title, onClose, size = 'md', header, children, footer }: Props = $props();\n\n const sizeStyles: Record<string, string> = {\n sm: 'max-width: var(--max-w-modal-sm)',\n md: 'max-width: var(--max-w-modal-md)',\n lg: 'max-width: var(--max-w-modal-lg)',\n xl: 'max-width: var(--max-w-modal-xl)'\n };\n<\/script>\n\n<Dialog open={open} onOpenChange={(e) => { if (!e.open) onClose(); }}>\n <Portal>\n <Dialog.Backdrop class=\"bg-black/50\" />\n <Dialog.Positioner>\n <Dialog.Content class=\"card shadow-xl overflow-hidden\" style={sizeStyles[size]}>\n <div class=\"modal-header border-b border-surface-300-700 flex items-center justify-between\">\n <Dialog.Title class=\"modal-title\">{title}</Dialog.Title>\n <Dialog.CloseTrigger class=\"btn-icon hover:bg-surface-200-700\" aria-label=\"Close\">\n ✕\n </Dialog.CloseTrigger>\n </div>\n\n <div class=\"modal-body\">\n {@render children()}\n </div>\n\n {#if footer}\n <div class=\"modal-header border-t border-surface-300-700\">\n {@render footer()}\n </div>\n {/if}\n </Dialog.Content>\n </Dialog.Positioner>\n </Portal>\n</Dialog>\n\n<style>\n .modal-header {\n padding: var(--modal-header-py) var(--modal-header-px);\n }\n\n .modal-title {\n font-size: var(--text-modal-title);\n font-weight: var(--weight-title);\n }\n\n .modal-body {\n padding: var(--modal-body-p);\n }\n</style>\n",
|
|
99
|
+
"/lib/components/ui/Breadcrumb.svelte": "<script lang=\"ts\">\n import Icon from '../icons/Icon.svelte';\n\n interface BreadcrumbItem {\n label: string;\n href?: string;\n }\n\n interface Props {\n items: BreadcrumbItem[];\n class?: string;\n }\n\n let { items, class: className = '' }: Props = $props();\n\n const lastIndex = $derived(items.length - 1);\n<\/script>\n\n{#if items.length > 0}\n <nav aria-label=\"Breadcrumb\" class=\"breadcrumb {className}\">\n <ol class=\"breadcrumb-list\">\n {#each items as item, i (i)}\n <li class=\"breadcrumb-item\">\n {#if i < lastIndex && item.href}\n <a href={item.href} class=\"breadcrumb-link\">\n {item.label}\n </a>\n {:else}\n <span class=\"breadcrumb-current\" aria-current=\"page\">\n {item.label}\n </span>\n {/if}\n\n {#if i < lastIndex}\n <span class=\"breadcrumb-separator\" aria-hidden=\"true\">\n <Icon name=\"chevronRight\" size={14} />\n </span>\n {/if}\n </li>\n {/each}\n </ol>\n </nav>\n{/if}\n\n<style>\n .breadcrumb-list {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: var(--gap-xs);\n list-style: none;\n margin: 0;\n padding: 0;\n }\n\n .breadcrumb-item {\n display: flex;\n align-items: center;\n gap: var(--gap-xs);\n }\n\n .breadcrumb-link {\n font-size: var(--text-body);\n color: var(--color-surface-500);\n text-decoration: none;\n transition: color 0.15s ease;\n }\n\n .breadcrumb-link:hover {\n color: var(--color-primary-500);\n text-decoration: underline;\n }\n\n .breadcrumb-current {\n font-size: var(--text-body);\n color: var(--color-surface-400-600);\n font-weight: var(--weight-label);\n }\n\n .breadcrumb-separator {\n display: inline-flex;\n align-items: center;\n color: var(--color-surface-300-700);\n }\n</style>\n",
|
|
100
|
+
"/lib/components/ui/SearchInput.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n interface Props {\n value?: string;\n placeholder?: string;\n debounce?: number;\n loading?: boolean;\n class?: string;\n name?: string;\n }\n\n let {\n value = $bindable(''),\n placeholder = 'Search...',\n debounce = 300,\n loading = false,\n class: className = '',\n name = 'search'\n }: Props = $props();\n\n let inputValue = $state(value);\n\n // Debounce: local input → parent value\n $effect(() => {\n const current = inputValue;\n const timeout = setTimeout(() => {\n value = current;\n }, debounce);\n return () => clearTimeout(timeout);\n });\n\n const wrapperClass = $derived(\n cn(\n 'relative flex items-center',\n 'border border-surface-300-700',\n 'bg-surface-50-800',\n 'transition-all duration-200',\n 'focus-within:ring-2 focus-within:ring-primary-500/50 focus-within:border-primary-500',\n className\n )\n );\n\n const inputClass = $derived(\n cn(\n 'w-full bg-transparent border-0 focus:outline-none',\n 'text-surface-900-100',\n 'placeholder:text-surface-400-500',\n 'disabled:opacity-50 disabled:cursor-not-allowed'\n )\n );\n<\/script>\n\n<div class={wrapperClass} style=\"border-radius: var(--radius-input-custom)\">\n <span class=\"absolute left-0 flex items-center justify-center pointer-events-none text-surface-400-500\" style=\"padding-left: var(--input-custom-px)\">\n <Icon name=\"search\" size={16} />\n </span>\n\n <input\n {name}\n type=\"text\"\n bind:value={inputValue}\n {placeholder}\n class={inputClass}\n style=\"padding: var(--input-custom-py) var(--input-custom-px); padding-left: calc(var(--input-custom-px) + 1.25rem); border-radius: var(--radius-input-custom); font-size: var(--text-body)\"\n />\n\n {#if loading}\n <span class=\"absolute right-0 flex items-center justify-center text-surface-400-500\" style=\"padding-right: var(--input-custom-px)\">\n <Icon name=\"loader2\" size={16} class=\"animate-spin\" />\n </span>\n {/if}\n</div>\n",
|
|
101
|
+
"/lib/components/ui/Progress.svelte": "<script lang=\"ts\">\n import { Progress } from '@skeletonlabs/skeleton-svelte';\n import { cn } from './utils/cn';\n\n type ProgressSize = 'sm' | 'md' | 'lg';\n\n interface Props {\n /** Current progress value (0 to max) */\n value: number;\n /** Maximum value (default 100) */\n max?: number;\n /** Accessible label read by screen readers */\n label?: string;\n /** Height variant of the progress bar */\n size?: ProgressSize;\n /** Additional CSS classes applied to the root wrapper */\n class?: string;\n }\n\n let { value, max = 100, label, size = 'md', class: className = '' }: Props = $props();\n\n const sizeMap: Record<ProgressSize, string> = {\n sm: 'sf-progress-sm',\n md: 'sf-progress-md',\n lg: 'sf-progress-lg'\n };\n\n const rootClass = $derived(cn('sf-progress', sizeMap[size], className));\n\n const percentage = $derived(Math.min(Math.max((value / max) * 100, 0), 100));\n<\/script>\n\n<div class={rootClass}>\n {#if label}\n <span class=\"sf-progress-label\">{label}</span>\n {/if}\n\n <Progress.Root {value} {max} aria-label={label ?? 'Progress'}>\n <Progress.Track class=\"sf-progress-track\">\n <Progress.Range class=\"sf-progress-range\" />\n </Progress.Track>\n </Progress.Root>\n\n <span class=\"sf-progress-value\" aria-hidden=\"true\">{Math.round(percentage)}%</span>\n</div>\n\n<style>\n .sf-progress {\n display: flex;\n align-items: center;\n gap: var(--gap-sm);\n }\n\n .sf-progress-label {\n font-size: var(--text-caption);\n font-weight: var(--weight-label);\n color: var(--color-surface-600-400);\n white-space: nowrap;\n }\n\n .sf-progress-track {\n flex: 1;\n border-radius: 9999px;\n overflow: hidden;\n background-color: var(--color-surface-200-800);\n }\n\n .sf-progress-sm .sf-progress-track {\n height: var(--progress-sm);\n }\n\n .sf-progress-md .sf-progress-track {\n height: var(--progress-md);\n }\n\n .sf-progress-lg .sf-progress-track {\n height: var(--progress-lg);\n }\n\n .sf-progress-range {\n height: 100%;\n border-radius: 9999px;\n background-color: var(--color-primary-500);\n transition: width 0.3s ease;\n }\n\n .sf-progress-value {\n font-size: var(--text-caption);\n font-weight: var(--weight-label);\n color: var(--color-surface-600-400);\n min-width: 2.5rem;\n text-align: right;\n tabular-nums: true;\n }\n</style>\n",
|
|
102
|
+
"/lib/components/ui/Accordion.svelte": "<script lang=\"ts\">\n import { Accordion } from '@skeletonlabs/skeleton-svelte';\n import { cn } from './utils/cn';\n\n interface AccordionItem {\n value: string;\n title: string;\n content: string;\n }\n\n interface Props {\n /** Array of items to render. Each needs a unique `value`, a `title`, and `content`. */\n items: AccordionItem[];\n /** Allow multiple items to be open simultaneously */\n multiple?: boolean;\n /** Additional CSS classes applied to the root wrapper */\n class?: string;\n }\n\n let { items, multiple = false, class: className = '' }: Props = $props();\n\n const rootClass = $derived(cn('sf-accordion', className));\n<\/script>\n\n{#if items.length > 0}\n <Accordion.Root {multiple} class={rootClass}>\n {#each items as item (item.value)}\n <Accordion.Item value={item.value} class=\"sf-accordion-item\">\n <Accordion.ItemTrigger class=\"sf-accordion-trigger\">\n <span class=\"sf-accordion-title\">{item.title}</span>\n <Accordion.ItemIndicator class=\"sf-accordion-indicator\" />\n </Accordion.ItemTrigger>\n <Accordion.ItemContent class=\"sf-accordion-content\">\n {item.content}\n </Accordion.ItemContent>\n </Accordion.Item>\n {/each}\n </Accordion.Root>\n{/if}\n\n<style>\n .sf-accordion {\n display: flex;\n flex-direction: column;\n gap: var(--gap-xs);\n }\n\n .sf-accordion-item {\n border: 1px solid var(--color-surface-200-800);\n border-radius: var(--radius-card);\n overflow: hidden;\n }\n\n .sf-accordion-trigger {\n display: flex;\n align-items: center;\n justify-content: space-between;\n width: 100%;\n padding: var(--accordion-trigger-py) var(--accordion-trigger-px);\n font-size: var(--text-body);\n font-weight: var(--weight-label);\n color: var(--color-surface-700-300);\n cursor: pointer;\n background: transparent;\n border: none;\n text-align: left;\n transition: background-color 0.15s ease, color 0.15s ease;\n }\n\n .sf-accordion-trigger:hover {\n background-color: var(--color-surface-100-900);\n color: var(--color-surface-900-100);\n }\n\n .sf-accordion-title {\n flex: 1;\n }\n\n .sf-accordion-indicator {\n transition: transform 0.2s ease;\n }\n\n .sf-accordion-content {\n padding: 0 var(--accordion-content-px) var(--accordion-content-py);\n font-size: var(--text-body);\n color: var(--color-surface-600-400);\n line-height: 1.5;\n }\n</style>\n",
|
|
103
|
+
"/lib/components/ui/Badge.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n\n type BadgeVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'surface';\n\n interface Props {\n variant?: BadgeVariant;\n class?: string;\n children: any;\n }\n\n let { variant = 'surface', class: className = '', children }: Props = $props();\n\n const variants: Record<BadgeVariant, string> = {\n primary: 'preset-tonal-primary-500',\n secondary: 'preset-tonal-secondary-500',\n success: 'preset-tonal-success-500',\n warning: 'preset-tonal-warning-500',\n error: 'preset-tonal-error-500',\n surface: 'preset-tonal-surface-500'\n };\n\n const badgeClass = $derived(cn('badge', variants[variant], className));\n<\/script>\n\n<span class={badgeClass}>{@render children()}</span>\n",
|
|
104
|
+
"/lib/components/ui/EmptyState.svelte.test.ts": "/**\n * EmptyState Component Tests\n *\n * Tests rendering of the EmptyState UI component including title,\n * description, icon, and action snippet.\n */\nimport { describe, it, expect, afterEach } from 'vitest';\nimport { render, screen, cleanup } from '@testing-library/svelte';\nimport EmptyState from './EmptyState.svelte';\n\ndescribe('EmptyState', () => {\n afterEach(() => cleanup());\n\n it('renders title text', () => {\n render(EmptyState, { title: 'Nothing here' });\n expect(screen.getByText('Nothing here')).toBeInTheDocument();\n });\n\n it('renders title as an h3 element', () => {\n render(EmptyState, { title: 'Empty' });\n const heading = screen.getByRole('heading', { level: 3 });\n expect(heading).toHaveTextContent('Empty');\n });\n\n it('renders description text when provided', () => {\n render(EmptyState, {\n title: 'No items',\n description: 'Add some items to get started'\n });\n expect(screen.getByText('Add some items to get started')).toBeInTheDocument();\n });\n\n it('does not render description element when not provided', () => {\n const { container } = render(EmptyState, { title: 'No items' });\n // No <p> element should exist when description is not provided\n expect(container.querySelector('p')).not.toBeInTheDocument();\n });\n\n // TODO: Action snippet rendering requires passing a real Svelte 5 Snippet\n // object, which cannot be created as a plain function in tests.\n // Skipped until a helper for creating Snippet objects is available.\n it.skip('renders action snippet when provided', () => {\n // Snippet props require a compiled Svelte 5 snippet, not a plain function\n });\n\n it('does not render action content when not provided', () => {\n render(EmptyState, { title: 'No data' });\n expect(screen.queryByRole('button')).not.toBeInTheDocument();\n });\n\n it('renders with default inbox icon container', () => {\n const { container } = render(EmptyState, { title: 'Empty' });\n // Icon renders as an inline-flex div\n const iconDiv = container.querySelector('.inline-flex');\n expect(iconDiv).toBeInTheDocument();\n });\n\n it('applies flex column layout classes', () => {\n const { container } = render(EmptyState, { title: 'Empty' });\n const wrapper = container.firstElementChild as HTMLElement;\n expect(wrapper.className).toContain('flex');\n expect(wrapper.className).toContain('flex-col');\n expect(wrapper.className).toContain('items-center');\n expect(wrapper.className).toContain('justify-center');\n });\n});\n",
|
|
105
|
+
"/lib/components/ui/NotificationBadge.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n\n interface Props {\n count: number;\n max?: number;\n size?: 'sm' | 'md';\n class?: string;\n }\n\n let {\n count,\n max = 99,\n size = 'sm',\n class: className = ''\n }: Props = $props();\n\n const displayText = $derived(count > max ? `${max}+` : `${count}`);\n\n const sizeClasses = {\n sm: 'min-w-4 h-4 text-[10px]',\n md: 'min-w-5 h-5 text-xs'\n };\n<\/script>\n\n<span\n class={cn(\n 'absolute -top-1 -right-1 bg-error-500 text-surface-50 rounded-full flex items-center justify-center font-semibold leading-none select-none',\n sizeClasses[size],\n className\n )}\n aria-label=\"{count} notification{count !== 1 ? 's' : ''}\"\n>\n {displayText}\n</span>\n",
|
|
106
|
+
"/lib/components/ui/Switch.svelte": "<script lang=\"ts\">\n import { Switch as SkeletonSwitch } from '@skeletonlabs/skeleton-svelte';\n\n interface Props {\n /** Whether the switch is checked */\n checked?: boolean;\n /** Label text */\n label?: string;\n /** Small description under the label */\n description?: string;\n /** Disabled state */\n disabled?: boolean;\n /** Change handler */\n onCheckedChange?: (checked: boolean) => void;\n /** Additional classes for the wrapper */\n class?: string;\n }\n\n let {\n checked = false,\n label,\n description,\n disabled = false,\n onCheckedChange,\n class: className = ''\n }: Props = $props();\n<\/script>\n\n<div class=\"flex items-center justify-between {className}\" style=\"gap: var(--gap-lg)\">\n {#if label}\n <div class=\"flex-1\">\n <span style=\"font-size: var(--text-label); font-weight: var(--weight-label)\">{label}</span>\n {#if description}\n <p class=\"text-surface-500\" style=\"font-size: var(--text-caption); margin-top: var(--space-nano)\">{description}</p>\n {/if}\n </div>\n {/if}\n\n <SkeletonSwitch\n {checked}\n {disabled}\n aria-label={label}\n onCheckedChange={(e: { checked: boolean }) => onCheckedChange?.(e.checked)}\n >\n <SkeletonSwitch.Control>\n <SkeletonSwitch.Thumb />\n </SkeletonSwitch.Control>\n </SkeletonSwitch>\n</div>\n",
|
|
107
|
+
"/lib/components/ui/EmptyState.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n import Icon from '$lib/components/icons/Icon.svelte';\n import type { Snippet } from 'svelte';\n\n interface Props {\n icon?: string;\n title: string;\n description?: string;\n class?: string;\n action?: Snippet;\n }\n\n let {\n icon = 'inbox',\n title,\n description,\n class: className = '',\n action\n }: Props = $props();\n<\/script>\n\n<div\n class={cn(\n 'flex flex-col items-center justify-center text-center',\n className\n )}\n style=\"gap: var(--gap-lg); padding: var(--gap-xl)\"\n>\n <Icon name={icon} size={48} class=\"text-surface-400-500\" />\n\n <h3\n class=\"text-surface-700-300\"\n style=\"font-size: var(--text-card-title); font-weight: var(--weight-title)\"\n >\n {title}\n </h3>\n\n {#if description}\n <p class=\"text-surface-600-400\" style=\"font-size: var(--text-body); max-width: 24rem\">\n {description}\n </p>\n {/if}\n\n {#if action}\n <div>\n {@render action()}\n </div>\n {/if}\n</div>\n",
|
|
108
|
+
"/lib/components/ui/SkeletonLoader.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n\n type Variant = 'text' | 'circular' | 'rectangular';\n\n interface Props {\n variant?: Variant;\n width?: string;\n height?: string;\n lines?: number;\n class?: string;\n }\n\n let {\n variant = 'text',\n width,\n height,\n lines = 3,\n class: className = ''\n }: Props = $props();\n\n const defaultHeights: Record<Variant, string> = {\n text: '1rem',\n circular: '3rem',\n rectangular: '6rem'\n };\n\n const resolvedWidth = $derived(width ?? (variant === 'circular' ? '3rem' : '100%'));\n const resolvedHeight = $derived(height ?? defaultHeights[variant]);\n<\/script>\n\n{#if variant === 'text'}\n <div class={cn('flex flex-col', className)} style=\"gap: var(--gap-sm)\">\n {#each Array(lines) as _, i}\n <div\n class=\"animate-pulse bg-surface-200-700 rounded\"\n style=\"width: {i === lines - 1 ? '75%' : resolvedWidth}; height: var(--text-body)\"\n ></div>\n {/each}\n </div>\n{:else if variant === 'circular'}\n <div\n class={cn('animate-pulse bg-surface-200-700 rounded-full shrink-0', className)}\n style=\"width: {resolvedWidth}; height: {resolvedHeight}\"\n ></div>\n{:else}\n <div\n class={cn('animate-pulse bg-surface-200-700', className)}\n style=\"width: {resolvedWidth}; height: {resolvedHeight}; border-radius: var(--radius-card)\"\n ></div>\n{/if}\n",
|
|
109
|
+
"/lib/components/ui/DataTable.svelte": "<script lang=\"ts\" generics=\"T extends Record<string, unknown>\">\n import type { Snippet } from 'svelte';\n\n interface Column {\n /** Column key (property name in data) */\n key: string;\n /** Display header */\n label: string;\n /** Optional custom cell renderer */\n cell?: Snippet<[T]>;\n /** Optional custom header cell renderer (overrides label) */\n headerCell?: Snippet<[]>;\n /** Header alignment */\n align?: 'left' | 'center' | 'right';\n /** Column width class */\n width?: string;\n /** Sortable */\n sortable?: boolean;\n }\n\n interface Props {\n /** Column definitions */\n columns: Column[];\n /** Row data */\n data: T[];\n /** Unique key property in data */\n rowKey?: string;\n /** Loading state */\n loading?: boolean;\n /** Empty state message */\n emptyMessage?: string;\n /** Called when a row is clicked */\n onRowClick?: (row: T) => void;\n /** Additional classes */\n class?: string;\n }\n\n let {\n columns,\n data,\n rowKey = 'id',\n loading = false,\n emptyMessage = 'No data found',\n onRowClick,\n class: className = ''\n }: Props = $props();\n\n let sortKey = $state('');\n let sortDir = $state<'asc' | 'desc'>('asc');\n\n function handleSort(key: string) {\n if (sortKey === key) {\n sortDir = sortDir === 'asc' ? 'desc' : 'asc';\n } else {\n sortKey = key;\n sortDir = 'asc';\n }\n }\n\n const sortedData = $derived(() => {\n if (!sortKey) return data;\n const sorted = [...data].sort((a, b) => {\n const av = a[sortKey];\n const bv = b[sortKey];\n if (av == null || bv == null) return 0;\n const cmp = String(av).localeCompare(String(bv), undefined, { numeric: true });\n return sortDir === 'asc' ? cmp : -cmp;\n });\n return sorted;\n });\n<\/script>\n\n<div class=\"overflow-x-auto {className}\">\n <table class=\"w-full\" style=\"font-size: var(--text-body)\">\n <thead>\n <tr class=\"border-b border-surface-300-700\">\n {#each columns as col (col.key)}\n <th\n class=\"text-left text-surface-500 uppercase tracking-wider {col.width ?? ''}\"\n style=\"padding: var(--table-cell-py) var(--table-cell-px); font-size: var(--text-caption); font-weight: var(--weight-subtitle)\"\n >\n {#if col.headerCell}\n {@render col.headerCell()}\n {:else if col.sortable}\n <button\n type=\"button\"\n class=\"inline-flex items-center hover:text-surface-900-100 transition-colors\"\n style=\"gap: var(--gap-xs)\"\n onclick={() => handleSort(col.key)}\n >\n {col.label}\n {#if sortKey === col.key}\n <span class=\"text-primary-500\">{sortDir === 'asc' ? '↑' : '↓'}</span>\n {/if}\n </button>\n {:else}\n {col.label}\n {/if}\n </th>\n {/each}\n </tr>\n </thead>\n\n <tbody>\n {#if loading}\n {#each columns as _}\n <tr class=\"border-b border-surface-200-800\">\n <td style=\"padding: var(--table-cell-py) var(--table-cell-px)\">\n <div class=\"h-4 bg-surface-200-700 rounded animate-pulse\"></div>\n </td>\n </tr>\n {/each}\n {:else if data.length === 0}\n <tr>\n <td colspan={columns.length} class=\"text-center text-surface-500\" style=\"padding: var(--table-empty-py) var(--table-cell-px)\">\n {emptyMessage}\n </td>\n </tr>\n {:else}\n {#each sortedData() as row (String(row[rowKey] ?? ''))}\n <tr\n class=\"border-b border-surface-200-800\n {onRowClick ? 'hover:bg-surface-100-800 cursor-pointer' : ''}\"\n onclick={() => onRowClick?.(row)}\n onkeydown={(e) => { if (e.key === 'Enter') onRowClick?.(row); }}\n role={onRowClick ? 'button' : undefined}\n tabindex={onRowClick ? 0 : undefined}\n >\n {#each columns as col (col.key)}\n <td style=\"padding: var(--table-cell-py) var(--table-cell-px)\">\n {#if col.cell}\n {@render col.cell(row)}\n {:else}\n {row[col.key] ?? '—'}\n {/if}\n </td>\n {/each}\n </tr>\n {/each}\n {/if}\n </tbody>\n </table>\n</div>\n",
|
|
110
|
+
"/lib/components/ui/toast-state.svelte.test.ts": "/**\n * Toast State Tests\n *\n * Tests the toast state management (addToast, removeToast, getToasts).\n * Uses Svelte 5 runes internally, so we skip if compilation fails.\n */\nimport { describe, it, expect, beforeEach, vi } from 'vitest';\n\nconst toastModule = await import('$lib/components/ui/toast-state.svelte').catch(() => null);\n\ndescribe.skipIf(!toastModule)('toast-state', () => {\n const { getToasts, addToast, removeToast } = toastModule!;\n\n beforeEach(() => {\n // Clear all toasts before each test\n const current = getToasts();\n for (const t of current) {\n removeToast(t.id);\n }\n });\n\n it('starts with no toasts (after cleanup)', () => {\n expect(getToasts()).toHaveLength(0);\n });\n\n it('addToast adds a toast and returns an id', () => {\n const id = addToast({ title: 'Hello' });\n expect(id).toBeDefined();\n expect(typeof id).toBe('string');\n expect(getToasts()).toHaveLength(1);\n });\n\n it('addToast defaults kind to \"info\"', () => {\n addToast({ title: 'Test' });\n expect(getToasts()[0].kind).toBe('info');\n });\n\n it('addToast uses provided kind', () => {\n addToast({ title: 'Test', kind: 'success' });\n expect(getToasts()[0].kind).toBe('success');\n });\n\n it('addToast includes description when provided', () => {\n addToast({ title: 'Test', description: 'Details here' });\n expect(getToasts()[0].description).toBe('Details here');\n });\n\n it('removeToast removes the specified toast', () => {\n const id1 = addToast({ title: 'First' });\n addToast({ title: 'Second' });\n expect(getToasts()).toHaveLength(2);\n\n removeToast(id1);\n expect(getToasts()).toHaveLength(1);\n expect(getToasts()[0].title).toBe('Second');\n });\n\n it('removeToast with invalid id does nothing', () => {\n addToast({ title: 'Test' });\n removeToast('non-existent-id');\n expect(getToasts()).toHaveLength(1);\n });\n\n it('addToast sets up auto-removal timeout (defaults to 5000ms)', () => {\n vi.useFakeTimers();\n addToast({ title: 'Auto remove' });\n expect(getToasts()).toHaveLength(1);\n\n vi.advanceTimersByTime(5000);\n expect(getToasts()).toHaveLength(0);\n\n vi.useRealTimers();\n });\n\n it('addToast respects custom timeout of 0 (no auto-remove)', () => {\n vi.useFakeTimers();\n addToast({ title: 'Persistent', timeout: 0 });\n expect(getToasts()).toHaveLength(1);\n\n vi.advanceTimersByTime(10000);\n expect(getToasts()).toHaveLength(1);\n\n vi.useRealTimers();\n });\n});\n",
|
|
111
|
+
"/lib/components/ui/Badge.svelte.test.ts": "/**\n * Badge Component Tests\n *\n * Tests rendering of the Badge UI component including text content,\n * variant classes, and snippet (children) rendering.\n */\nimport { describe, it, expect, afterEach } from 'vitest';\nimport { render, cleanup } from '@testing-library/svelte';\nimport { createRawSnippet } from 'svelte';\nimport Badge from './Badge.svelte';\n\n/**\n * Helper: create a Svelte 5 snippet that renders the given text.\n */\nfunction textSnippet(text: string) {\n return createRawSnippet(() => ({\n render: () => `<span>${text}</span>`\n }));\n}\n\ndescribe('Badge', () => {\n afterEach(() => cleanup());\n\n it('renders text content via children snippet', () => {\n const { container } = render(Badge, { children: textSnippet('Hello Badge') });\n expect(container.textContent).toContain('Hello Badge');\n });\n\n it('renders as a span element', () => {\n const { container } = render(Badge, { children: textSnippet('Test') });\n // The outer span is the badge wrapper\n const badge = container.querySelector('.badge');\n expect(badge).toBeInTheDocument();\n expect(badge?.tagName).toBe('SPAN');\n });\n\n it('applies badge base class', () => {\n const { container } = render(Badge, { children: textSnippet('Base') });\n const badge = container.querySelector('.badge');\n expect(badge).toBeInTheDocument();\n });\n\n it('applies default variant (surface) class', () => {\n const { container } = render(Badge, { children: textSnippet('Default') });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('preset-tonal-surface-500');\n });\n\n it('applies primary variant class', () => {\n const { container } = render(Badge, {\n variant: 'primary',\n children: textSnippet('Primary')\n });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('preset-tonal-primary-500');\n });\n\n it('applies secondary variant class', () => {\n const { container } = render(Badge, {\n variant: 'secondary',\n children: textSnippet('Secondary')\n });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('preset-tonal-secondary-500');\n });\n\n it('applies success variant class', () => {\n const { container } = render(Badge, {\n variant: 'success',\n children: textSnippet('Success')\n });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('preset-tonal-success-500');\n });\n\n it('applies warning variant class', () => {\n const { container } = render(Badge, {\n variant: 'warning',\n children: textSnippet('Warning')\n });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('preset-tonal-warning-500');\n });\n\n it('applies error variant class', () => {\n const { container } = render(Badge, {\n variant: 'error',\n children: textSnippet('Error')\n });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('preset-tonal-error-500');\n });\n\n it('applies custom class prop', () => {\n const { container } = render(Badge, {\n class: 'my-custom-class',\n children: textSnippet('Custom')\n });\n const badge = container.querySelector('.badge');\n expect(badge?.className).toContain('my-custom-class');\n });\n});\n",
|
|
112
|
+
"/lib/components/ui/Tabs.svelte": "<script lang=\"ts\">\n import { Tabs as SkeletonTabs } from '@skeletonlabs/skeleton-svelte';\n import type { Snippet } from 'svelte';\n\n interface TabItem {\n value: string;\n label: string;\n icon?: Snippet;\n content: Snippet;\n disabled?: boolean;\n }\n\n interface Props {\n tabs: TabItem[];\n value?: string;\n onValueChange?: (value: string) => void;\n variant?: 'underline' | 'pills';\n class?: string;\n }\n\n let {\n tabs,\n value = $bindable(tabs[0]?.value ?? ''),\n onValueChange,\n variant = 'underline',\n class: className = ''\n }: Props = $props();\n\n const variantClasses: Record<string, string> = {\n underline: '',\n pills: 'bg-surface-100-800'\n };\n\n const variantStyles: Record<string, string> = {\n underline: '',\n pills: 'border-radius: var(--radius-tab-pills); padding: var(--tab-pills-p)'\n };\n<\/script>\n\n{#if tabs.length > 0}\n <SkeletonTabs {value} onValueChange={(e: { value: string }) => { value = e.value; onValueChange?.(e.value); }}>\n <div class=\"{variantClasses[variant]} {className}\" style=\"{variantStyles[variant]}\">\n <SkeletonTabs.List class=\"flex\" style=\"gap: var(--gap-xs)\">\n {#each tabs as tab (tab.value)}\n <SkeletonTabs.Trigger value={tab.value} disabled={tab.disabled}>\n <div class=\"flex items-center\" style=\"gap: var(--gap-sm); padding: var(--tab-trigger-py) var(--tab-trigger-px); font-size: var(--text-body); font-weight: var(--weight-subtitle)\">\n {#if tab.icon}{@render tab.icon()}{/if}\n {tab.label}\n </div>\n </SkeletonTabs.Trigger>\n {/each}\n </SkeletonTabs.List>\n\n {#each tabs as tab (tab.value)}\n <SkeletonTabs.Content value={tab.value}>\n <div style=\"padding-top: var(--space-inline)\">\n {@render tab.content()}\n </div>\n </SkeletonTabs.Content>\n {/each}\n </div>\n </SkeletonTabs>\n{/if}\n",
|
|
113
|
+
"/lib/components/ui/Card.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n import type { Snippet } from 'svelte';\n\n type CardVariant = 'flat' | 'elevated' | 'outlined' | 'none';\n\n interface Props {\n class?: string;\n variant?: CardVariant;\n padding?: string;\n noPadding?: boolean;\n header?: Snippet;\n children: Snippet;\n footer?: Snippet;\n action?: Snippet; // Header action\n title?: string; // Shorthand for simple header\n icon?: any; // Shorthand icon name\n }\n\n let {\n class: className = '',\n variant = 'flat',\n padding = 'card-body',\n noPadding = false,\n header,\n children,\n footer,\n action,\n title,\n icon\n }: Props = $props();\n\n const variants: Record<CardVariant, string> = {\n flat: 'bg-surface-50-800 border border-surface-200-700',\n elevated:\n 'bg-surface-50-800 border border-surface-200-700 shadow-md',\n outlined: 'bg-transparent border border-surface-200-700',\n none: ''\n };\n\n const cardClass = $derived(cn('card overflow-hidden', variants[variant], className));\n<\/script>\n\n<div class={cardClass} style=\"border-radius: var(--radius-card)\">\n {#if header || title}\n <div\n class=\"card-header border-b border-surface-200-700 flex items-center justify-between\"\n >\n <div class=\"flex items-center gap-2\">\n {#if header}\n {@render header()}\n {:else if title}\n <h3 class=\"card-title flex items-center\">\n {#if icon}\n <div class=\"card-icon-wrap bg-primary-500/10 text-primary-500\">\n <!-- Icon component would be better here, but we use Snippet or generic for now -->\n <!-- Assuming Icon component usage elsewhere -->\n </div>\n {/if}\n {title}\n </h3>\n {/if}\n </div>\n {#if action}\n {@render action()}\n {/if}\n </div>\n {/if}\n\n <div class={noPadding ? '' : padding}>\n {@render children()}\n </div>\n\n {#if footer}\n <div\n class=\"card-footer border-t border-surface-300-700 preset-tonal-surface-500\"\n >\n {@render footer()}\n </div>\n {/if}\n</div>\n\n<style>\n .card-body {\n padding: var(--card-p);\n }\n @media (min-width: 640px) {\n .card-body {\n padding: var(--card-p-lg);\n }\n }\n\n .card-header {\n padding: var(--card-header-py) var(--card-header-px);\n }\n @media (min-width: 640px) {\n .card-header {\n padding: var(--card-header-py-lg) var(--card-header-px-lg);\n }\n }\n\n .card-footer {\n padding: var(--card-header-py) var(--card-header-px);\n }\n @media (min-width: 640px) {\n .card-footer {\n padding: var(--card-header-py-lg) var(--card-header-px-lg);\n }\n }\n\n .card-title {\n font-size: var(--text-card-title);\n font-weight: var(--weight-title);\n gap: var(--gap-sm);\n }\n @media (min-width: 640px) {\n .card-title {\n font-size: var(--text-card-title-lg);\n }\n }\n\n .card-icon-wrap {\n padding: var(--card-icon-p);\n border-radius: var(--radius-icon-wrap);\n }\n</style>\n",
|
|
114
|
+
"/lib/components/ui/AuthCard.svelte": "<script lang=\"ts\">\n import type { Snippet } from 'svelte';\n\n interface Props {\n title: string;\n subtitle?: string;\n children: Snippet;\n footer?: Snippet;\n }\n\n let { title, subtitle, children, footer }: Props = $props();\n<\/script>\n\n<div\n class=\"min-h-screen flex items-center justify-center bg-surface-100-900 p-4 animate-in fade-in duration-500\"\n>\n <div\n class=\"card w-full authcard bg-surface-50-800 border border-surface-200-700 shadow-xl\"\n >\n <div class=\"text-center auth-title-section\">\n <h1\n class=\"auth-title bg-gradient-to-r from-primary-600 to-primary-500 bg-clip-text text-transparent\"\n >\n {title}\n </h1>\n {#if subtitle}\n <p class=\"auth-subtitle text-surface-600-400\">\n {subtitle}\n </p>\n {/if}\n </div>\n\n {@render children()}\n\n {#if footer}\n <div class=\"auth-footer border-t border-surface-300-700\">\n {@render footer()}\n </div>\n {/if}\n </div>\n</div>\n\n<style>\n .authcard {\n max-width: var(--max-w-authcard);\n border-radius: var(--radius-authcard);\n padding: var(--authcard-p);\n }\n @media (min-width: 768px) {\n .authcard {\n padding: var(--authcard-p-lg);\n }\n }\n\n .auth-title-section {\n margin-bottom: var(--space-section);\n }\n\n .auth-title {\n font-size: var(--text-auth-title);\n font-weight: var(--weight-title);\n }\n @media (min-width: 768px) {\n .auth-title {\n font-size: var(--text-auth-title-lg);\n }\n }\n\n .auth-subtitle {\n margin-top: var(--gap-sm);\n font-size: var(--text-body);\n }\n\n .auth-footer {\n margin-top: var(--space-group);\n padding-top: var(--space-group);\n }\n</style>\n",
|
|
115
|
+
"/lib/components/ui/Sheet.svelte": "<script lang=\"ts\">\n import { Dialog, Portal } from '@skeletonlabs/skeleton-svelte';\n import type { Snippet } from 'svelte';\n import { cn } from './utils/cn';\n\n type Side = 'left' | 'right' | 'top' | 'bottom';\n\n interface Props {\n /** Whether the sheet is visible. Two-way bindable. */\n open: boolean;\n /** Which edge the sheet slides in from. */\n side?: Side;\n /** Optional heading displayed in the sheet header. */\n title?: string;\n /** Additional CSS classes for the sheet panel. */\n class?: string;\n /** Sheet body content. */\n children: Snippet;\n /** Optional footer content rendered at the bottom. */\n footer?: Snippet;\n }\n\n let {\n open = $bindable(false),\n side = 'right',\n title,\n class: className = '',\n children,\n footer\n }: Props = $props();\n\n /**\n * Per-side configuration: how the positioner aligns the panel and which\n * translate axis/direction is used for the slide-in animation.\n */\n const sideConfig = $derived.by(() => {\n const configs: Record<Side, { positioner: string; dimensions: string }> = {\n left: {\n positioner: 'flex justify-start',\n dimensions: 'h-full'\n },\n right: {\n positioner: 'flex justify-end',\n dimensions: 'h-full'\n },\n top: {\n positioner: 'flex flex-col items-start',\n dimensions: 'w-full'\n },\n bottom: {\n positioner: 'flex flex-col items-end justify-end',\n dimensions: 'w-full'\n }\n };\n return configs[side];\n });\n\n /** Animation classes driven by Dialog's data-state attribute. */\n const animBackdrop =\n 'transition transition-discrete opacity-0 starting:data-[state=open]:opacity-0 data-[state=open]:opacity-100';\n\n const animContent = $derived.by(() => {\n const isHorizontal = side === 'left' || side === 'right';\n const isNegative = side === 'left' || side === 'top';\n\n const initial = isHorizontal\n ? isNegative\n ? '-translate-x-full'\n : 'translate-x-full'\n : isNegative\n ? '-translate-y-full'\n : 'translate-y-full';\n\n const final = isHorizontal ? 'translate-x-0' : 'translate-y-0';\n\n return [\n 'transition',\n 'transition-discrete',\n 'opacity-0',\n initial,\n `starting:data-[state=open]:opacity-0`,\n `starting:data-[state=open]:${initial}`,\n `data-[state=open]:opacity-100`,\n `data-[state=open]:${final}`\n ].join(' ');\n });\n\n const panelClass = $derived(\n cn(\n 'sheet-panel card bg-surface-50-950 shadow-xl overflow-y-auto',\n sideConfig.dimensions,\n animContent,\n className\n )\n );\n\n function handleClose() {\n open = false;\n }\n<\/script>\n\n<Dialog {open} onOpenChange={(e) => { open = e.open; }}>\n <Portal>\n <Dialog.Backdrop\n class=\"fixed inset-0 z-50 bg-surface-50-950/50 {animBackdrop}\"\n />\n <Dialog.Positioner class=\"fixed inset-0 z-50 {sideConfig.positioner}\">\n <Dialog.Content class={panelClass}>\n {#if title}\n <div class=\"sheet-header border-b border-surface-200-700 flex items-center justify-between\">\n <Dialog.Title class=\"sheet-title\">{title}</Dialog.Title>\n <Dialog.CloseTrigger\n class=\"btn-icon hover:bg-surface-200-700\"\n aria-label=\"Close panel\"\n >\n ✕\n </Dialog.CloseTrigger>\n </div>\n {:else}\n <div class=\"sheet-header-no-title flex justify-end\">\n <Dialog.CloseTrigger\n class=\"btn-icon hover:bg-surface-200-700\"\n aria-label=\"Close panel\"\n >\n ✕\n </Dialog.CloseTrigger>\n </div>\n {/if}\n\n <div class=\"sheet-body\">\n {@render children()}\n </div>\n\n {#if footer}\n <div class=\"sheet-footer border-t border-surface-200-700\">\n {@render footer()}\n </div>\n {/if}\n </Dialog.Content>\n </Dialog.Positioner>\n </Portal>\n</Dialog>\n\n<style>\n :global(.sheet-panel) {\n width: min(100vw, 24rem);\n }\n\n :global(.flex-col) > :global(.sheet-panel) {\n width: 100vw;\n height: auto;\n max-height: 80vh;\n }\n\n .sheet-header {\n padding: var(--modal-header-py) var(--modal-header-px);\n }\n\n .sheet-header-no-title {\n padding: var(--modal-header-py) var(--modal-header-px);\n }\n\n :global(.sheet-title) {\n font-size: var(--text-modal-title);\n font-weight: var(--weight-title);\n }\n\n .sheet-body {\n padding: var(--modal-body-p);\n }\n\n .sheet-footer {\n padding: var(--modal-header-py) var(--modal-header-px);\n }\n</style>\n",
|
|
116
|
+
"/lib/components/ui/Carousel.svelte": "<script lang=\"ts\">\n import { Carousel } from '@skeletonlabs/skeleton-svelte';\n import type { Snippet } from 'svelte';\n import { cn } from './utils/cn';\n\n interface Props {\n /** Total number of slides. Required by the underlying Carousel. */\n slideCount: number;\n /** Snippet rendered inside the slide area. Wrap each slide in `<Carousel.Item index={i}>`. */\n children: Snippet;\n /** Number of slides visible per page. */\n slidesPerPage?: number;\n /** Gap between slides, e.g. `'16px'`. */\n spacing?: string;\n /** Show an autoplay toggle in the control bar. */\n autoplay?: boolean;\n /** Loop back to the first slide after the last. */\n loop?: boolean;\n /** Render previous / next navigation controls. */\n showControls?: boolean;\n /** Render dot indicators below the carousel. */\n showDots?: boolean;\n /** Additional CSS classes for the wrapper element. */\n class?: string;\n }\n\n let {\n slideCount,\n children,\n slidesPerPage = 1,\n spacing = '16px',\n autoplay = false,\n loop = false,\n showControls = true,\n showDots = true,\n class: className = ''\n }: Props = $props();\n\n const wrapperClass = $derived(cn('carousel-wrapper', className));\n<\/script>\n\n<div class={wrapperClass} style=\"border-radius: var(--radius-card)\">\n <Carousel {slideCount} {slidesPerPage} {spacing} {loop}>\n {#if showControls}\n <Carousel.Control class=\"carousel-controls\" style=\"gap: var(--gap-sm)\">\n <Carousel.PrevTrigger class=\"btn preset-tonal-surface-500\" aria-label=\"Previous slide\">\n <span>←</span>\n <span>Back</span>\n </Carousel.PrevTrigger>\n\n {#if autoplay}\n <Carousel.AutoplayTrigger class=\"btn preset-tonal-surface-500\">\n Autoplay\n </Carousel.AutoplayTrigger>\n {/if}\n\n <Carousel.NextTrigger class=\"btn preset-tonal-surface-500\" aria-label=\"Next slide\">\n <span>Next</span>\n <span>→</span>\n </Carousel.NextTrigger>\n </Carousel.Control>\n {/if}\n\n <Carousel.ItemGroup>\n {@render children()}\n </Carousel.ItemGroup>\n\n {#if showDots}\n <Carousel.IndicatorGroup class=\"carousel-dots\" style=\"gap: var(--gap-sm)\">\n <Carousel.Context>\n {#snippet children(carousel)}\n {#each carousel().pageSnapPoints as _, index (index)}\n <Carousel.Indicator {index} class=\"carousel-dot\" />\n {/each}\n {/snippet}\n </Carousel.Context>\n </Carousel.IndicatorGroup>\n {/if}\n </Carousel>\n</div>\n\n<style>\n .carousel-wrapper {\n overflow: hidden;\n }\n\n .carousel-controls {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: var(--gap-sm);\n }\n\n .carousel-dots {\n display: flex;\n justify-content: center;\n margin-top: var(--gap-sm);\n }\n\n .carousel-dot {\n width: 8px;\n height: 8px;\n border-radius: 50%;\n background-color: var(--color-surface-400-600);\n transition: background-color 0.2s ease, transform 0.2s ease;\n }\n\n .carousel-dot:global([data-active='true']) {\n background-color: var(--color-primary-500);\n transform: scale(1.25);\n }\n\n .carousel-dot:hover {\n background-color: var(--color-surface-500);\n }\n</style>\n",
|
|
117
|
+
"/lib/components/ui/Avatar.svelte": "<script lang=\"ts\">\n import type { Snippet } from 'svelte';\n\n interface Props {\n /** URL to the image */\n src?: string | null;\n /** Alt text for the image */\n alt?: string;\n /** Fallback initials when no image (e.g. \"JD\") */\n fallback?: string;\n /** Size: sm (32px), md (40px), lg (48px), xl (64px) */\n size?: 'sm' | 'md' | 'lg' | 'xl';\n /** Additional classes */\n class?: string;\n }\n\n let { src, alt = '', fallback = '', size = 'md', class: className = '' }: Props = $props();\n\n const sizeStyles: Record<string, string> = {\n sm: 'width: var(--avatar-sm); height: var(--avatar-sm); font-size: var(--text-caption)',\n md: 'width: var(--avatar-md); height: var(--avatar-md); font-size: var(--text-label)',\n lg: 'width: var(--avatar-lg); height: var(--avatar-lg); font-size: var(--text-submit)',\n xl: 'width: var(--avatar-xl); height: var(--avatar-xl); font-size: 1.125rem'\n };\n\n const initials = $derived(\n fallback ||\n (alt\n ? alt\n .split(' ')\n .map((w) => w[0])\n .join('')\n .toUpperCase()\n .slice(0, 2)\n : '?')\n );\n<\/script>\n\n<div\n class=\"relative inline-flex items-center justify-center rounded-full bg-primary-500/10 text-primary-500 overflow-hidden {className}\"\n style=\"{sizeStyles[size]}; font-weight: var(--weight-title)\"\n>\n {#if src}\n <img {src} {alt} class=\"absolute inset-0 w-full h-full object-cover\" />\n {:else}\n {initials}\n {/if}\n</div>\n",
|
|
118
|
+
"/lib/components/ui/DataTable.svelte.test.ts": "/**\n * DataTable Component Tests\n *\n * Tests rendering of the DataTable UI component including headers,\n * data rows, empty state message, loading, and sorting.\n */\nimport { describe, it, expect, afterEach } from 'vitest';\nimport { render, screen, fireEvent, cleanup } from '@testing-library/svelte';\nimport DataTable from './DataTable.svelte';\n\nconst columns = [\n { key: 'name', label: 'Name' },\n { key: 'email', label: 'Email' },\n { key: 'role', label: 'Role' }\n];\n\nconst data = [\n { id: '1', name: 'Alice', email: 'alice@example.com', role: 'Admin' },\n { id: '2', name: 'Bob', email: 'bob@example.com', role: 'User' },\n { id: '3', name: 'Charlie', email: 'charlie@example.com', role: 'Editor' }\n];\n\ndescribe('DataTable', () => {\n afterEach(() => cleanup());\n\n it('renders table with headers', () => {\n render(DataTable, { columns, data: [] });\n expect(screen.getByText('Name')).toBeInTheDocument();\n expect(screen.getByText('Email')).toBeInTheDocument();\n expect(screen.getByText('Role')).toBeInTheDocument();\n });\n\n it('renders headers inside th elements', () => {\n const { container } = render(DataTable, { columns, data: [] });\n const thElements = container.querySelectorAll('th');\n expect(thElements).toHaveLength(3);\n expect(thElements[0].textContent).toContain('Name');\n expect(thElements[1].textContent).toContain('Email');\n expect(thElements[2].textContent).toContain('Role');\n });\n\n it('renders rows with data', () => {\n render(DataTable, { columns, data });\n expect(screen.getByText('Alice')).toBeInTheDocument();\n expect(screen.getByText('bob@example.com')).toBeInTheDocument();\n expect(screen.getByText('Editor')).toBeInTheDocument();\n });\n\n it('renders correct number of data rows', () => {\n const { container } = render(DataTable, { columns, data });\n const tbody = container.querySelector('tbody');\n const rows = tbody?.querySelectorAll('tr');\n expect(rows).toHaveLength(3);\n });\n\n it('shows empty state message when no data', () => {\n render(DataTable, { columns, data: [] });\n expect(screen.getByText('No data found')).toBeInTheDocument();\n });\n\n it('shows custom empty message when provided', () => {\n render(DataTable, {\n columns,\n data: [],\n emptyMessage: 'No users available'\n });\n expect(screen.getByText('No users available')).toBeInTheDocument();\n });\n\n it('empty state row spans all columns', () => {\n const { container } = render(DataTable, { columns, data: [] });\n const td = container.querySelector('td[colspan]');\n expect(td).toBeInTheDocument();\n expect(td?.getAttribute('colspan')).toBe('3');\n });\n\n it('renders cell values from data', () => {\n const { container } = render(DataTable, { columns, data });\n const cells = container.querySelectorAll('tbody td');\n // 3 rows × 3 columns = 9 cells\n expect(cells).toHaveLength(9);\n // First row cells\n expect(cells[0].textContent).toBe('Alice');\n expect(cells[1].textContent).toBe('alice@example.com');\n expect(cells[2].textContent).toBe('Admin');\n });\n\n it('renders a table element', () => {\n const { container } = render(DataTable, { columns, data });\n expect(container.querySelector('table')).toBeInTheDocument();\n });\n\n it('renders loading skeleton rows when loading is true', () => {\n const { container } = render(DataTable, {\n columns,\n data: [],\n loading: true\n });\n // Loading state renders skeleton rows with animate-pulse\n const skeletonRows = container.querySelectorAll('.animate-pulse');\n expect(skeletonRows.length).toBeGreaterThan(0);\n });\n\n it('sortable column renders as a button', () => {\n const sortableColumns = [\n { key: 'name', label: 'Name', sortable: true },\n { key: 'email', label: 'Email' }\n ];\n render(DataTable, { columns: sortableColumns, data: [] });\n const buttons = screen.getAllByRole('button');\n // At least the sortable header button\n expect(buttons.length).toBeGreaterThanOrEqual(1);\n expect(buttons[0].textContent).toContain('Name');\n });\n\n it('clicking sortable column toggles sort direction', async () => {\n const sortableColumns = [\n { key: 'name', label: 'Name', sortable: true },\n { key: 'email', label: 'Email' }\n ];\n render(DataTable, {\n columns: sortableColumns,\n data\n });\n\n const sortButton = screen.getByRole('button', { name: /name/i });\n await fireEvent.click(sortButton);\n // After first click: ascending sort indicator\n expect(sortButton.textContent).toContain('↑');\n\n await fireEvent.click(sortButton);\n // After second click: descending sort indicator\n expect(sortButton.textContent).toContain('↓');\n });\n});\n",
|
|
119
|
+
"/lib/components/ui/Menu.svelte": "<script lang=\"ts\">\n import { Menu as SkeletonMenu, Portal } from '@skeletonlabs/skeleton-svelte';\n import type { Snippet } from 'svelte';\n\n interface MenuItem {\n /** Unique key */\n key: string;\n /** Display label */\n label: string;\n /** Optional icon snippet */\n icon?: Snippet;\n /** Disabled */\n disabled?: boolean;\n /** Danger style */\n danger?: boolean;\n }\n\n interface Props {\n /** Trigger element */\n trigger: Snippet;\n /** Menu items */\n items: MenuItem[];\n /** Called when an item is selected */\n onSelect: (key: string) => void;\n /** Alignment */\n align?: 'start' | 'end';\n /** Additional classes for the trigger wrapper */\n class?: string;\n }\n\n let { trigger, items, onSelect, align = 'start', class: className = '' }: Props = $props();\n<\/script>\n\n<SkeletonMenu>\n <SkeletonMenu.Trigger>\n {@render trigger()}\n </SkeletonMenu.Trigger>\n\n <Portal>\n <SkeletonMenu.Positioner>\n <SkeletonMenu.Content class=\"card shadow-lg border border-surface-300-700 z-50\" style=\"padding: var(--menu-panel-p); min-width: var(--menu-min-w)\">\n {#each items as item (item.key)}\n <button\n type=\"button\"\n class=\"w-full flex items-center transition-colors\n {item.disabled\n ? 'opacity-50 cursor-not-allowed'\n : item.danger\n ? 'text-error-500 hover:bg-error-500/10'\n : 'hover:bg-surface-200-700'\n }\"\n style=\"gap: var(--gap-sm); padding: var(--menu-item-py) var(--menu-item-px); font-size: var(--text-body); border-radius: var(--radius-menu-item)\"\n disabled={item.disabled}\n onclick={() => { if (!item.disabled) onSelect(item.key); }}\n >\n {#if item.icon}\n <span class=\"shrink-0\">{@render item.icon()}</span>\n {/if}\n {item.label}\n </button>\n {/each}\n </SkeletonMenu.Content>\n </SkeletonMenu.Positioner>\n </Portal>\n</SkeletonMenu>\n",
|
|
120
|
+
"/lib/components/ui/ConfirmDialog.svelte": "<script lang=\"ts\">\n import { Dialog, Portal } from '@skeletonlabs/skeleton-svelte';\n\n interface Props {\n open: boolean;\n title: string;\n message: string;\n confirmLabel?: string;\n cancelLabel?: string;\n variant?: 'danger' | 'warning' | 'primary';\n onConfirm: () => void;\n onCancel: () => void;\n }\n\n let {\n open,\n title,\n message,\n confirmLabel = 'Confirm',\n cancelLabel = 'Cancel',\n variant = 'danger',\n onConfirm,\n onCancel\n }: Props = $props();\n\n const variantClasses: Record<string, string> = {\n danger: 'preset-filled-error-500',\n warning: 'preset-filled-warning-500',\n primary: 'preset-filled-primary-500'\n };\n<\/script>\n\n<Dialog open={open} onOpenChange={(e) => { if (!e.open) onCancel(); }}>\n <Portal>\n <Dialog.Backdrop class=\"bg-black/50\" />\n <Dialog.Positioner>\n <Dialog.Content class=\"card shadow-xl\" style=\"max-width: var(--max-w-dialog); padding: var(--dialog-p)\">\n <Dialog.Title style=\"font-size: var(--text-dialog-title); font-weight: var(--weight-subtitle); margin-bottom: var(--space-element)\">{title}</Dialog.Title>\n <p class=\"text-surface-600-400\" style=\"margin-bottom: var(--space-group)\">{message}</p>\n <div class=\"flex justify-end\" style=\"gap: var(--gap-md)\">\n <button class=\"btn preset-outlined-secondary-500\" onclick={onCancel}>{cancelLabel}</button>\n <button class=\"btn {variantClasses[variant]}\" onclick={onConfirm}>{confirmLabel}</button>\n </div>\n </Dialog.Content>\n </Dialog.Positioner>\n </Portal>\n</Dialog>\n",
|
|
121
|
+
"/lib/components/ui/Loader.svelte": "<script lang=\"ts\">\n interface Props {\n message?: string;\n }\n\n let { message = 'Loading...' }: Props = $props();\n<\/script>\n\n<div class=\"flex flex-col items-center justify-center gap-4 p-8\">\n <div class=\"w-48 h-2 bg-surface-200-700 rounded-full overflow-hidden\">\n <div class=\"h-full bg-primary-500 rounded-full animate-pulse\"></div>\n </div>\n {#if message}\n <p class=\"text-sm text-surface-600-400\">{message}</p>\n {/if}\n</div>\n",
|
|
122
|
+
"/lib/components/ui/toast-state.svelte.ts": "/**\n * Toast state management\n *\n * Usage:\n * ```svelte\n * <script>\n * import { addToast, removeToast } from '$lib/components/ui/toast-state.svelte';\n * import Toast from '$lib/components/ui/Toast.svelte';\n * <\/script>\n *\n * <Toast />\n *\n * <button onclick={() => addToast({ title: 'Saved!' })}>Save</button>\n * ```\n */\n\ninterface ToastItem {\n id: string;\n kind: 'info' | 'success' | 'warning' | 'error';\n title: string;\n description?: string;\n timeout?: number;\n}\n\nlet toasts = $state<ToastItem[]>([]);\n\nexport function getToasts(): ToastItem[] {\n return toasts;\n}\n\nexport function addToast(opts: {\n kind?: 'info' | 'success' | 'warning' | 'error';\n title: string;\n description?: string;\n timeout?: number;\n}): string {\n const id = crypto.randomUUID();\n const entry: ToastItem = {\n id,\n kind: opts.kind ?? 'info',\n title: opts.title,\n description: opts.description,\n timeout: opts.timeout ?? 5000\n };\n toasts = [...toasts, entry];\n\n if (entry.timeout && entry.timeout > 0) {\n setTimeout(() => removeToast(id), entry.timeout);\n }\n\n return id;\n}\n\nexport function removeToast(id: string): void {\n toasts = toasts.filter((t) => t.id !== id);\n}\n",
|
|
123
|
+
"/lib/components/ui/Button.svelte": "<script lang=\"ts\">\n import { cn } from './utils/cn';\n\n type ButtonVariant =\n | 'primary'\n | 'secondary'\n | 'outline'\n | 'ghost'\n | 'danger'\n | 'success'\n | 'glass'\n | 'cta'\n | 'tonal'\n | 'none';\n type ButtonSize = 'sm' | 'md' | 'lg';\n\n interface Props {\n variant?: ButtonVariant;\n size?: ButtonSize;\n class?: string;\n style?: string;\n children: any;\n onclick?: (event: MouseEvent) => void;\n onmouseenter?: () => void;\n onmouseleave?: () => void;\n disabled?: boolean;\n loading?: boolean;\n type?: 'button' | 'submit' | 'reset';\n href?: string;\n target?: string;\n ariaLabel?: string;\n id?: string;\n }\n\n let {\n variant = 'primary',\n size = 'md',\n class: className = '',\n style: styleAttr = '',\n children,\n onclick,\n onmouseenter,\n onmouseleave,\n disabled = false,\n loading = false,\n type = 'button',\n href,\n target,\n ariaLabel,\n id\n }: Props = $props();\n\n // Utilise les presets SkeletonUI pour une meilleure intégration du thème\n const variants = {\n primary: 'preset-filled-primary-500',\n secondary: 'preset-filled-secondary-500',\n outline: 'preset-outlined-primary-500',\n ghost:\n 'hover:bg-surface-200-800 text-surface-700-300 hover:text-surface-900-50',\n danger: 'preset-filled-error-500',\n success: 'preset-filled-success-500',\n glass:\n 'backdrop-blur-md bg-primary-500/20 border-2 border-primary-400/50 text-primary-300 hover:bg-primary-500/30 hover:border-primary-400/70 hover:text-primary-200 transition-all duration-300',\n cta: 'preset-filled-primary-500 shadow-lg shadow-primary-500/25 hover:shadow-xl hover:shadow-primary-500/30 transition-all duration-300 motion-reduce:transition-none btn-shine',\n tonal: 'preset-tonal-primary',\n none: ''\n };\n\n const sizes = {\n sm: 'btn-sm',\n md: 'btn-base',\n lg: 'btn-lg'\n };\n\n const buttonClass = $derived(cn('btn', variants[variant], sizes[size], className));\n<\/script>\n\n<svelte:element\n this={href ? 'a' : 'button'}\n {href}\n {id}\n target={href ? target : undefined}\n rel={target === '_blank' ? 'noopener noreferrer' : undefined}\n type={href ? undefined : type}\n {onclick}\n {onmouseenter}\n {onmouseleave}\n disabled={href ? undefined : disabled || loading}\n aria-disabled={href && disabled ? 'true' : undefined}\n aria-label={ariaLabel}\n role={href ? 'button' : undefined}\n class={buttonClass}\n style={styleAttr || undefined}\n>\n {#if loading}\n <span class=\"flex items-center justify-center gap-2\">\n <span class=\"w-4 h-4 border-2 border-current/30 border-t-current rounded-full animate-spin\"\n ></span>\n {@render children()}\n </span>\n {:else}\n {@render children()}\n {/if}\n</svelte:element>\n",
|
|
124
|
+
"/lib/components/ui/Toast.svelte": "<script lang=\"ts\">\n import { getToasts, removeToast } from './toast-state.svelte';\n\n const kindPresets: Record<string, string> = {\n info: 'preset-tonal-primary-500',\n success: 'preset-tonal-success-500',\n warning: 'preset-tonal-warning-500',\n error: 'preset-tonal-error-500'\n };\n<\/script>\n\n{#if getToasts().length > 0}\n <div class=\"fixed bottom-4 right-4 z-50 flex flex-col\" style=\"max-width: var(--max-w-toast); gap: var(--gap-md)\">\n {#each getToasts() as toast (toast.id)}\n <div\n class=\"card {kindPresets[toast.kind] ?? kindPresets.info} shadow-lg flex items-start transition-all duration-300\"\n style=\"padding: var(--toast-p); gap: var(--gap-md)\"\n >\n <div class=\"flex-1 min-w-0\">\n <p class=\"font-semibold\" style=\"font-size: var(--text-body)\">{toast.title}</p>\n {#if toast.description}\n <p class=\"mt-1 opacity-80\" style=\"font-size: var(--text-caption)\">{toast.description}</p>\n {/if}\n </div>\n <button\n type=\"button\"\n onclick={() => removeToast(toast.id)}\n class=\"shrink-0 text-current opacity-50 hover:opacity-100 transition-opacity\"\n aria-label=\"Close\"\n >\n ✕\n </button>\n </div>\n {/each}\n </div>\n{/if}\n",
|
|
125
|
+
"/lib/components/ui/PopOver.svelte": "<script lang=\"ts\">\n import { Popover, Portal } from '@skeletonlabs/skeleton-svelte';\n import type { Snippet } from 'svelte';\n\n interface Props {\n side?: 'top' | 'right' | 'bottom' | 'left';\n class?: string;\n children: Snippet;\n content: Snippet;\n }\n\n let {\n side = 'bottom',\n class: className = '',\n children,\n content\n }: Props = $props();\n<\/script>\n\n<Popover positioning={{ placement: side }}>\n <Popover.Trigger>\n {@render children()}\n </Popover.Trigger>\n\n <Portal>\n <Popover.Positioner>\n <Popover.Content class=\"popover-content card shadow-lg border border-surface-300-700 {className}\">\n {@render content()}\n </Popover.Content>\n </Popover.Positioner>\n </Portal>\n</Popover>\n\n<style>\n .popover-content {\n padding: var(--card-p);\n border-radius: var(--radius-card);\n z-index: 50;\n }\n</style>\n",
|
|
126
|
+
"/lib/components/ui/Stepper.svelte": "<script lang=\"ts\">\n import Icon from '../icons/Icon.svelte';\n import { cn } from './utils/cn';\n\n interface Step {\n label: string;\n description?: string;\n }\n\n interface Props {\n steps: Step[];\n currentStep?: number;\n orientation?: 'horizontal' | 'vertical';\n class?: string;\n }\n\n let {\n steps,\n currentStep = $bindable(0),\n orientation = 'horizontal',\n class: className = ''\n }: Props = $props();\n\n function getStepStatus(index: number): 'completed' | 'current' | 'upcoming' {\n if (index < currentStep) return 'completed';\n if (index === currentStep) return 'current';\n return 'upcoming';\n }\n<\/script>\n\n{#if steps.length > 0}\n <div\n class={cn('stepper', `stepper-${orientation}`, className)}\n role=\"group\"\n aria-label=\"Progress steps\"\n >\n {#each steps as step, i (i)}\n {@const status = getStepStatus(i)}\n\n <div class={cn('step', `step-${status}`)}>\n <!-- Step circle -->\n <div class=\"step-circle-wrapper\">\n <div\n class={cn('step-circle', `step-circle-${status}`)}\n aria-current={status === 'current' ? 'step' : undefined}\n >\n {#if status === 'completed'}\n <Icon name=\"check\" size={14} class=\"step-icon\" />\n {:else}\n <span class=\"step-number\">{i + 1}</span>\n {/if}\n </div>\n\n <!-- Connector line -->\n {#if i < steps.length - 1}\n <div\n class={cn(\n 'step-connector',\n `step-connector-${status === 'completed' ? 'completed' : 'incomplete'}`\n )}\n ></div>\n {/if}\n </div>\n\n <!-- Step label -->\n <div class=\"step-content\">\n <span class={cn('step-label', `step-label-${status}`)}>{step.label}</span>\n {#if step.description}\n <span class=\"step-description\">{step.description}</span>\n {/if}\n </div>\n </div>\n {/each}\n </div>\n{/if}\n\n<style>\n /* Layout */\n .stepper {\n display: flex;\n gap: var(--gap-lg);\n }\n\n .stepper-horizontal {\n flex-direction: row;\n align-items: flex-start;\n }\n\n .stepper-vertical {\n flex-direction: column;\n }\n\n /* Step row/column */\n .step {\n display: flex;\n flex-direction: row;\n align-items: center;\n gap: var(--gap-sm);\n flex: 1;\n }\n\n .stepper-vertical .step {\n flex: 0;\n }\n\n /* Circle + connector wrapper */\n .step-circle-wrapper {\n display: flex;\n flex-direction: row;\n align-items: center;\n flex: 1;\n }\n\n .stepper-vertical .step-circle-wrapper {\n flex-direction: column;\n align-self: stretch;\n flex: 0;\n }\n\n /* Circle */\n .step-circle {\n width: 2rem;\n height: 2rem;\n min-width: 2rem;\n border-radius: 50%;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: var(--text-caption);\n font-weight: var(--weight-title);\n transition: all 0.2s ease;\n }\n\n .step-circle-completed {\n background-color: var(--color-success-500);\n color: white;\n }\n\n .step-circle-current {\n background-color: var(--color-primary-500);\n color: white;\n box-shadow: 0 0 0 3px var(--color-primary-500 / 20%);\n }\n\n .step-circle-upcoming {\n background-color: var(--color-surface-200-800);\n color: var(--color-surface-400-600);\n border: 2px solid var(--color-surface-300-700);\n }\n\n .step-icon {\n display: flex;\n align-items: center;\n justify-content: center;\n color: white;\n }\n\n /* Connector */\n .step-connector {\n flex: 1;\n height: 2px;\n margin: 0 var(--gap-xs);\n transition: background-color 0.2s ease;\n }\n\n .stepper-vertical .step-connector {\n width: 2px;\n height: 1.5rem;\n margin: var(--gap-xs) 0;\n align-self: center;\n }\n\n .step-connector-completed {\n background-color: var(--color-success-500);\n }\n\n .step-connector-incomplete {\n background-color: var(--color-surface-300-700);\n }\n\n /* Content */\n .step-content {\n display: flex;\n flex-direction: column;\n gap: 0;\n }\n\n .stepper-horizontal .step-content {\n min-width: 0;\n position: absolute;\n top: 2.5rem;\n text-align: center;\n }\n\n .stepper-horizontal .step {\n position: relative;\n flex-direction: column;\n align-items: center;\n }\n\n .stepper-horizontal .step-circle-wrapper {\n flex: 0;\n }\n\n .step-label {\n font-size: var(--text-body);\n font-weight: var(--weight-label);\n white-space: nowrap;\n }\n\n .step-label-completed {\n color: var(--color-success-500);\n }\n\n .step-label-current {\n color: var(--color-primary-500);\n font-weight: var(--weight-title);\n }\n\n .step-label-upcoming {\n color: var(--color-surface-400-600);\n }\n\n .step-description {\n font-size: var(--text-caption);\n color: var(--color-surface-400-600);\n }\n\n .step-number {\n line-height: 1;\n }\n</style>\n",
|
|
127
|
+
"/lib/components/ui/rich-text/RichTextEditor.svelte": "<script lang=\"ts\">\n import { onMount, onDestroy } from 'svelte';\n import { browser } from '$app/environment';\n import { Editor } from '@tiptap/core';\n import StarterKit from '@tiptap/starter-kit';\n import Underline from '@tiptap/extension-underline';\n import type { JSONContent } from '@tiptap/core';\n import Icon from '../../icons/Icon.svelte';\n\n interface Props {\n /** Initial content as Tiptap JSON */\n content?: JSONContent;\n /** Called on every content change with the full JSON document */\n onUpdate?: (json: JSONContent) => void;\n /** Called when the editor gains focus */\n onFocus?: () => void;\n /** Called when the editor loses focus */\n onBlur?: () => void;\n /** Whether the editor is editable. Default: true */\n editable?: boolean;\n /** Placeholder text for empty editor */\n placeholder?: string;\n /** Additional CSS class */\n class?: string;\n }\n\n let {\n content,\n onUpdate,\n onFocus,\n onBlur,\n editable = true,\n placeholder = 'Start writing...',\n class: className = ''\n }: Props = $props();\n\n let editor = $state<Editor | null>(null);\n let editorEl = $state<HTMLElement | null>(null);\n\n onMount(() => {\n if (!browser || !editorEl) return;\n\n editor = new Editor({\n element: editorEl,\n extensions: [StarterKit, Underline],\n content: content ?? { type: 'doc', content: [{ type: 'paragraph' }] },\n editable,\n editorProps: {\n attributes: {\n class: `sf-rte__content prose prose-slate max-w-none focus:outline-none min-h-[120px] ${className}`,\n 'data-placeholder': placeholder\n }\n },\n onUpdate: ({ editor: e }) => {\n onUpdate?.(e.getJSON());\n },\n onFocus: () => {\n onFocus?.();\n },\n onBlur: () => {\n onBlur?.();\n }\n });\n });\n\n // Sync external content changes\n $effect(() => {\n if (!editor || !content) return;\n const current = JSON.stringify(editor.getJSON());\n const incoming = JSON.stringify(content);\n if (current !== incoming) {\n editor.commands.setContent(content, { emitUpdate: false });\n }\n });\n\n onDestroy(() => {\n editor?.destroy();\n });\n\n // Toolbar helpers\n function isActive(type: string, attrs?: Record<string, unknown>): boolean {\n return editor?.isActive(type, attrs) ?? false;\n }\n\n function cmd(fn: (e: Editor) => void) {\n if (!editor) return;\n editor.chain().focus();\n fn(editor);\n }\n<\/script>\n\n<div class=\"sf-rte\" class:sf-rte--disabled={!editable}>\n {#if editable}\n <!-- Toolbar -->\n <div class=\"sf-rte__toolbar\">\n <!-- Text formatting -->\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('bold') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleBold().run())}\n title=\"Bold\"\n aria-label=\"Bold\"\n >\n <Icon name=\"bold\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('italic') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleItalic().run())}\n title=\"Italic\"\n aria-label=\"Italic\"\n >\n <Icon name=\"italic\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('underline') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleUnderline().run())}\n title=\"Underline\"\n aria-label=\"Underline\"\n >\n <Icon name=\"underline\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('strike') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleStrike().run())}\n title=\"Strikethrough\"\n aria-label=\"Strikethrough\"\n >\n <Icon name=\"strikethrough\" size={16} />\n </button>\n\n <div class=\"sf-rte__sep\"></div>\n\n <!-- Headings -->\n {#each [1, 2, 3] as level}\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('heading', { level }) ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 }).run())}\n title=\"Heading {level}\"\n aria-label=\"Heading {level}\"\n >\n H{level}\n </button>\n {/each}\n\n <div class=\"sf-rte__sep\"></div>\n\n <!-- Lists -->\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('bulletList') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleBulletList().run())}\n title=\"Bullet list\"\n aria-label=\"Bullet list\"\n >\n <Icon name=\"list\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('orderedList') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleOrderedList().run())}\n title=\"Ordered list\"\n aria-label=\"Ordered list\"\n >\n <Icon name=\"listOrdered\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('blockquote') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleBlockquote().run())}\n title=\"Quote\"\n aria-label=\"Quote\"\n >\n <Icon name=\"quote\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn {isActive('codeBlock') ? 'sf-rte__btn--active' : ''}\"\n onclick={() => cmd((e) => e.chain().focus().toggleCodeBlock().run())}\n title=\"Code block\"\n aria-label=\"Code block\"\n >\n <Icon name=\"code\" size={16} />\n </button>\n\n <div class=\"sf-rte__sep\"></div>\n\n <!-- Utilities -->\n <button\n type=\"button\"\n class=\"sf-rte__btn\"\n onclick={() => cmd((e) => e.chain().focus().setHorizontalRule().run())}\n title=\"Horizontal rule\"\n aria-label=\"Horizontal rule\"\n >\n <Icon name=\"minus\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn\"\n onclick={() => cmd((e) => e.chain().focus().undo().run())}\n title=\"Undo\"\n aria-label=\"Undo\"\n >\n <Icon name=\"undo2\" size={16} />\n </button>\n <button\n type=\"button\"\n class=\"sf-rte__btn\"\n onclick={() => cmd((e) => e.chain().focus().redo().run())}\n title=\"Redo\"\n aria-label=\"Redo\"\n >\n <Icon name=\"redo2\" size={16} />\n </button>\n </div>\n {/if}\n\n <!-- Editor area -->\n <div bind:this={editorEl} class=\"sf-rte__body\"></div>\n</div>\n\n<style>\n .sf-rte {\n display: flex;\n flex-direction: column;\n border-radius: var(--radius-input-custom, 0.5rem);\n border: 1px solid var(--color-surface-300-700, #d1d5db);\n overflow: hidden;\n transition: border-color 0.15s;\n }\n\n .sf-rte:focus-within {\n border-color: var(--color-primary-500, #4f6d92);\n }\n\n .sf-rte--disabled {\n opacity: 0.6;\n pointer-events: none;\n }\n\n /* Toolbar */\n .sf-rte__toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 2px;\n padding: var(--gap-xs, 0.25rem) var(--gap-sm, 0.5rem);\n background-color: var(--color-surface-100-900, #f3f4f6);\n border-bottom: 1px solid var(--color-surface-300-700, #d1d5db);\n }\n\n .sf-rte__btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 2rem;\n height: 2rem;\n border-radius: var(--radius-icon-wrap, 0.375rem);\n border: none;\n background: transparent;\n color: var(--color-surface-600-400, #4b5563);\n cursor: pointer;\n font-size: 0.75rem;\n font-weight: 700;\n transition:\n background-color 0.15s,\n color 0.15s;\n }\n\n .sf-rte__btn:hover {\n background-color: var(--color-surface-200-800, #e5e7eb);\n }\n\n .sf-rte__btn--active {\n background-color: var(--color-primary-100-900, #dbeafe);\n color: var(--color-primary-600-400, #2563eb);\n }\n\n .sf-rte__sep {\n width: 1px;\n height: 1.25rem;\n background-color: var(--color-surface-300-700, #d1d5db);\n margin: 0 var(--gap-xs, 0.25rem);\n }\n\n /* Body */\n .sf-rte__body {\n padding: var(--gap-md, 0.75rem);\n min-height: 120px;\n }\n\n /* Placeholder */\n .sf-rte__content:empty::before {\n content: attr(data-placeholder);\n color: var(--color-surface-400-600, #9ca3af);\n pointer-events: none;\n float: left;\n height: 0;\n }\n\n /* Prose overrides for consistent spacing */\n .sf-rte__body :global(h1) {\n font-size: 1.875rem;\n font-weight: 700;\n line-height: 1.2;\n margin-top: 1.5rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte__body :global(h2) {\n font-size: 1.5rem;\n font-weight: 700;\n line-height: 1.3;\n margin-top: 1.25rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte__body :global(h3) {\n font-size: 1.25rem;\n font-weight: 600;\n line-height: 1.4;\n margin-top: 1rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte__body :global(p) {\n margin-bottom: 0.5rem;\n }\n .sf-rte__body :global(ul),\n .sf-rte__body :global(ol) {\n margin-left: 1.5rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte__body :global(blockquote) {\n border-left: 3px solid var(--color-primary-500, #4f6d92);\n padding-left: 0.75rem;\n margin: 0.5rem 0;\n color: var(--color-surface-500, #6b7280);\n }\n .sf-rte__body :global(pre) {\n background-color: var(--color-surface-900-100, #111827);\n color: var(--color-surface-100-900, #f3f4f6);\n padding: 0.75rem;\n border-radius: 0.375rem;\n overflow-x: auto;\n font-size: 0.875rem;\n }\n .sf-rte__body :global(code) {\n background-color: var(--color-surface-200-800, #e5e7eb);\n padding: 0.125rem 0.25rem;\n border-radius: 0.25rem;\n font-size: 0.875em;\n }\n .sf-rte__body :global(pre code) {\n background: none;\n padding: 0;\n }\n .sf-rte__body :global(hr) {\n border: none;\n border-top: 1px solid var(--color-surface-300-700, #d1d5db);\n margin: 1rem 0;\n }\n</style>\n",
|
|
128
|
+
"/lib/components/ui/rich-text/index.ts": "export { default as RichTextEditor } from './RichTextEditor.svelte';\nexport { default as RichTextPreview } from './RichTextPreview.svelte';\n",
|
|
129
|
+
"/lib/components/ui/rich-text/RichTextPreview.svelte": "<script lang=\"ts\">\n import type { JSONContent } from '@tiptap/core';\n\n interface Props {\n /** Tiptap JSON content to render */\n content: JSONContent;\n /** Additional CSS class */\n class?: string;\n }\n\n let { content, class: className = '' }: Props = $props();\n\n /** Lightweight JSON→HTML renderer. No Editor needed — zero runtime cost. */\n function renderNode(node: JSONContent): string {\n if (!node) return '';\n\n const children = (node.content ?? []).map((c) => renderNode(c)).join('');\n const text = node.text ?? '';\n const marks = node.marks ?? [];\n\n function applyMarks(t: string, m: JSONContent['marks']): string {\n if (!m) return t;\n return m.reduce((acc, mark) => {\n switch (mark.type) {\n case 'bold':\n return `<strong>${acc}</strong>`;\n case 'italic':\n return `<em>${acc}</em>`;\n case 'underline':\n return `<u>${acc}</u>`;\n case 'strike':\n return `<s>${acc}</s>`;\n case 'code':\n return `<code>${acc}</code>`;\n default:\n return acc;\n }\n }, t);\n }\n\n switch (node.type) {\n case 'doc':\n return children;\n case 'paragraph':\n return `<p>${children || '<br>'}</p>`;\n case 'heading': {\n const level = (node.attrs?.level as number) ?? 1;\n return `<h${level}>${children}</h${level}>`;\n }\n case 'bulletList':\n return `<ul>${children}</ul>`;\n case 'orderedList':\n return `<ol>${children}</ol>`;\n case 'listItem':\n return `<li>${children}</li>`;\n case 'blockquote':\n return `<blockquote>${children}</blockquote>`;\n case 'codeBlock':\n return `<pre><code>${children}</code></pre>`;\n case 'hardBreak':\n return '<br>';\n case 'horizontalRule':\n return '<hr>';\n case 'text':\n return applyMarks(text, marks);\n default:\n return children || text;\n }\n }\n\n const html = $derived(renderNode(content));\n<\/script>\n\n<div class=\"sf-rte-preview prose prose-slate max-w-none {className}\">\n {@html html}\n</div>\n\n<style>\n .sf-rte-preview {\n line-height: 1.7;\n }\n\n .sf-rte-preview :global(h1) {\n font-size: 1.875rem;\n font-weight: 700;\n line-height: 1.2;\n margin-top: 1.5rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte-preview :global(h2) {\n font-size: 1.5rem;\n font-weight: 700;\n line-height: 1.3;\n margin-top: 1.25rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte-preview :global(h3) {\n font-size: 1.25rem;\n font-weight: 600;\n line-height: 1.4;\n margin-top: 1rem;\n margin-bottom: 0.5rem;\n }\n .sf-rte-preview :global(p) {\n margin-bottom: 0.75rem;\n }\n .sf-rte-preview :global(ul),\n .sf-rte-preview :global(ol) {\n margin-left: 1.5rem;\n margin-bottom: 0.75rem;\n }\n .sf-rte-preview :global(blockquote) {\n border-left: 3px solid var(--color-primary-500, #4f6d92);\n padding-left: 0.75rem;\n color: var(--color-surface-600-400, #4b5563);\n }\n .sf-rte-preview :global(code) {\n background-color: var(--color-surface-200-800, #e5e7eb);\n padding: 0.125rem 0.25rem;\n border-radius: 0.25rem;\n font-size: 0.875em;\n }\n .sf-rte-preview :global(pre) {\n background-color: var(--color-surface-900-100, #111827);\n color: var(--color-surface-100-900, #f3f4f6);\n padding: 0.75rem;\n border-radius: 0.375rem;\n overflow-x: auto;\n }\n .sf-rte-preview :global(pre code) {\n background: none;\n padding: 0;\n }\n .sf-rte-preview :global(hr) {\n border: none;\n border-top: 1px solid var(--color-surface-300-700, #d1d5db);\n margin: 1rem 0;\n }\n .sf-rte-preview :global(a) {\n color: var(--color-primary-500, #4f6d92);\n text-decoration: underline;\n }\n</style>\n",
|
|
130
|
+
"/lib/components/ui/form/PasswordInput.svelte": "<script lang=\"ts\">\n import Icon from '$lib/components/icons/Icon.svelte';\n import { cn } from '../utils/cn';\n\n interface Props {\n id: string;\n label: string;\n value: string | undefined;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n error?: string;\n showStrength?: boolean;\n minLength?: number;\n class?: string;\n name?: string;\n onblur?: () => void;\n }\n\n let {\n id,\n label,\n value = $bindable(),\n placeholder = '••••••••',\n required = false,\n disabled = false,\n error = '',\n showStrength = false,\n minLength = 8,\n class: className = '',\n name,\n onblur\n }: Props = $props();\n\n // Local state for the input value\n let initialValue = value ?? '';\n let inputValue = $state(initialValue);\n\n // Sync input value back to parent\n $effect(() => {\n value = inputValue;\n });\n\n let showPassword = $state(false);\n\n function getPasswordStrength(password: string): {\n strength: number;\n color: string;\n label: string;\n } {\n if (!password) return { strength: 0, color: 'bg-surface-300-600', label: '' };\n\n let score = 0;\n if (password.length >= minLength) score++;\n if (password.length >= minLength + 4) score++;\n if (/[a-z]/.test(password) && /[A-Z]/.test(password)) score++;\n if (/\\d/.test(password)) score++;\n if (/[^a-zA-Z0-9]/.test(password)) score++;\n\n const levels = [\n { color: 'bg-error-500', label: 'Very weak' },\n { color: 'bg-warning-500', label: 'Weak' },\n { color: 'bg-yellow-500', label: 'Fair' },\n { color: 'bg-lime-500', label: 'Strong' },\n { color: 'bg-success-500', label: 'Very strong' }\n ];\n\n return { strength: score, ...levels[Math.min(score, 4)] };\n }\n\n const strength = $derived(getPasswordStrength(inputValue));\n const progressWidth = $derived(`${(strength.strength / 5) * 100}%`);\n\n const togglePassword = () => {\n showPassword = !showPassword;\n };\n\n const containerClass = $derived(cn('space-y-1', className));\n\n const inputWrapperClass = $derived(\n cn(\n 'relative flex items-center',\n 'border border-surface-300-700',\n 'transition-all duration-200',\n 'focus-within:ring-2 focus-within:ring-primary-500/50 focus-within:border-primary-500',\n error && 'border-error-500 focus-within:ring-error-500/50 focus-within:border-error-500'\n )\n );\n\n const inputClass = $derived(\n cn(\n 'w-full pr-10',\n 'bg-transparent border-0 focus:outline-none',\n 'text-surface-900-100',\n 'placeholder:text-surface-400-500',\n 'disabled:opacity-50 disabled:cursor-not-allowed'\n )\n );\n\n const toggleButtonClass = $derived(\n cn(\n 'absolute right-2 p-1.5',\n 'text-surface-400-600 hover:text-surface-600-400',\n 'hover:bg-surface-100-700',\n 'transition-all duration-200',\n 'focus:outline-none focus:ring-2 focus:ring-primary-500/50',\n 'disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent'\n )\n );\n<\/script>\n\n<div class={containerClass}>\n <label for={id} class=\"label\">\n <span class=\"label-text flex justify-between items-center\" style=\"gap: var(--gap-lg); font-size: var(--text-label); font-weight: var(--weight-label)\">\n <span class=\"flex items-center\" style=\"gap: var(--gap-sm)\">\n {label}\n {#if required}\n <span class=\"text-error-500\">*</span>\n {/if}\n </span>\n {#if error}\n <span class=\"text-error-500 shrink-0\" style=\"font-size: var(--text-caption); font-weight: var(--weight-label)\">{error}</span>\n {/if}\n </span>\n </label>\n\n <div class={inputWrapperClass} style=\"border-radius: var(--radius-input-custom)\">\n <input\n {id}\n {name}\n type={showPassword ? 'text' : 'password'}\n bind:value={inputValue}\n {placeholder}\n {required}\n {disabled}\n {onblur}\n class={inputClass}\n style=\"padding-left: var(--input-custom-px); padding-top: var(--input-custom-py); padding-bottom: var(--input-custom-py); border-radius: var(--radius-input-custom); font-size: var(--text-body)\"\n />\n <button\n type=\"button\"\n onclick={togglePassword}\n class={toggleButtonClass}\n style=\"border-radius: var(--radius-toggle)\"\n aria-label={showPassword ? 'Hide password' : 'Show password'}\n tabindex=\"-1\"\n >\n {#if showPassword}\n <Icon name=\"eyeOff\" size={16} />\n {:else}\n <Icon name=\"eye\" size={16} />\n {/if}\n </button>\n </div>\n\n {#if showStrength && inputValue}\n <div class=\"space-y-1\" style=\"margin-top: var(--space-inline)\">\n <div class=\"flex items-center justify-between\" style=\"font-size: var(--text-caption)\">\n <span class=\"text-surface-600-400 flex items-center\" style=\"gap: var(--gap-xs)\">\n {#if strength.strength === 0}\n <Icon name=\"shield\" size={12} />\n {:else if strength.strength <= 2}\n <Icon name=\"shieldX\" size={12} />\n {:else}\n <Icon name=\"shieldCheck\" size={12} />\n {/if}\n Strength: {strength.label}\n </span>\n </div>\n <div class=\"w-full bg-surface-300-600 rounded-full overflow-hidden\" style=\"height: var(--strength-bar-h)\">\n <div\n class=\"h-full transition-all duration-300 ease-out {strength.color}\"\n style=\"width: {progressWidth}\"\n ></div>\n </div>\n </div>\n {/if}\n</div>\n",
|
|
131
|
+
"/lib/components/ui/form/index.ts": "import Input from './Input.svelte';\nimport TextArea from './TextArea.svelte';\nimport FormField from './FormField.svelte';\nimport PasswordInput from './PasswordInput.svelte';\nimport Checkbox from './Checkbox.svelte';\nimport Select from './Select.svelte';\nimport SubmitButton from './SubmitButton.svelte';\n\nexport { Input, TextArea, FormField, PasswordInput, Checkbox, Select, SubmitButton };\n",
|
|
132
|
+
"/lib/components/ui/form/SubmitButton.svelte": "<script lang=\"ts\">\n import Button from '../Button.svelte';\n import { cn } from '../utils/cn';\n\n interface Props {\n /** Loading state - shows spinner and loadingText */\n loading?: boolean;\n /** Button text when not loading */\n text: string;\n /** Button text when loading (default: 'Loading...') */\n loadingText?: string;\n /** Additional disabled state (combined with loading) */\n disabled?: boolean;\n /** Button variant (default: 'primary') */\n variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger' | 'glass' | 'cta' | 'tonal';\n /** Button size (default: 'md') */\n size?: 'sm' | 'md' | 'lg';\n /** Additional CSS classes */\n class?: string;\n }\n\n let {\n loading = false,\n text,\n loadingText = 'Loading...',\n disabled = false,\n variant = 'primary',\n size = 'md',\n class: className\n }: Props = $props();\n<\/script>\n\n<Button\n type=\"submit\"\n disabled={disabled || loading}\n {variant}\n {size}\n class={cn(\n 'w-full hover:scale-[1.02] active:scale-[0.98] transition-transform',\n className\n )}\n style=\"padding-top: var(--submit-py); padding-bottom: var(--submit-py); font-size: var(--text-submit); font-weight: var(--weight-label)\"\n>\n {#if loading}\n {loadingText}\n {:else}\n {text}\n {/if}\n</Button>\n",
|
|
133
|
+
"/lib/components/ui/form/Input.svelte": "<script lang=\"ts\">\n import { type InputType } from './form-shared';\n import { cn } from '../utils/cn';\n\n interface Props {\n id?: string;\n name?: string;\n type?: InputType;\n value?: string | number | null;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n error?: boolean;\n min?: string | number;\n max?: string | number;\n step?: string | number;\n class?: string;\n onkeydown?: (event: KeyboardEvent) => void;\n onblur?: () => void;\n oninput?: (event: Event) => void;\n }\n\n let {\n id,\n name,\n type = 'text',\n value = $bindable('' as string | number),\n placeholder = '',\n required = false,\n disabled = false,\n error = false,\n min,\n max,\n step,\n class: className = '',\n onkeydown,\n onblur,\n oninput\n }: Props = $props();\n\n const normalClass = 'input';\n\n const errorClass =\n 'input border-error-500 text-error-900-100 placeholder:text-error-400-500';\n\n const inputClass = $derived(cn(error ? errorClass : normalClass, className));\n<\/script>\n\n<input\n {id}\n {name}\n {type}\n bind:value\n {placeholder}\n {required}\n {disabled}\n {min}\n {max}\n {step}\n {onkeydown}\n {onblur}\n {oninput}\n class={inputClass}\n/>\n",
|
|
134
|
+
"/lib/components/ui/form/Checkbox.svelte": "<script lang=\"ts\">\n import { cn } from '../utils/cn';\n import type { Snippet } from 'svelte';\n\n interface Props {\n id?: string;\n name?: string;\n checked: boolean;\n label?: string;\n children?: Snippet;\n disabled?: boolean;\n required?: boolean;\n class?: string;\n onchange?: (checked: boolean) => void;\n }\n\n let {\n id,\n name,\n checked = $bindable(false),\n label,\n children,\n disabled = false,\n required = false,\n class: className = '',\n onchange\n }: Props = $props();\n\n function handleChange(e: Event) {\n const target = e.target as HTMLInputElement;\n checked = target.checked;\n onchange?.(checked);\n }\n\n const checkboxId = $derived(id || name || 'checkbox');\n\n const containerClass = $derived(\n cn(\n 'flex items-center cursor-pointer',\n className,\n disabled && 'opacity-50 cursor-not-allowed'\n )\n );\n\n const labelClass = $derived(\n cn(\n 'leading-relaxed select-none text-surface-700-300',\n disabled && 'text-surface-500'\n )\n );\n<\/script>\n\n<label class={containerClass} style=\"gap: var(--gap-sm)\">\n <input\n type=\"checkbox\"\n id={checkboxId}\n {name}\n bind:checked\n {disabled}\n {required}\n onchange={handleChange}\n class=\"checkbox\"\n />\n {#if children}\n {@render children()}\n {:else if label}\n <span class={labelClass} style=\"font-size: var(--text-label)\">\n {label}\n {#if required}\n <span class=\"text-error-500 ml-1\">*</span>\n {/if}\n </span>\n {/if}\n</label>\n",
|
|
135
|
+
"/lib/components/ui/form/Select.svelte": "<script lang=\"ts\">\n import { cn } from '../utils/cn';\n\n interface SelectOption {\n value: string;\n label: string;\n disabled?: boolean;\n }\n\n interface Props {\n id: string;\n name?: string;\n value: string;\n label?: string;\n options: SelectOption[];\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n error?: string;\n class?: string;\n }\n\n let {\n id,\n name,\n value = $bindable(''),\n label,\n options,\n placeholder,\n required = false,\n disabled = false,\n error = '',\n class: className = ''\n }: Props = $props();\n\n const containerClass = $derived(cn('space-y-1', className));\n\n const selectClass = $derived(\n cn(\n 'select-input',\n error && 'border-error-500 focus:ring-error-500/50 focus:border-error-500'\n )\n );\n\n const labelClass = $derived(cn('label', error && 'text-error-500'));\n<\/script>\n\n<div class={containerClass}>\n {#if label}\n <label for={id} class={labelClass}>\n <span class=\"label-text\">{label}</span>\n {#if required}\n <span class=\"text-error-500 ml-1\">*</span>\n {/if}\n </label>\n {/if}\n <select {id} {name} bind:value {required} {disabled} class={selectClass}>\n {#if placeholder}\n <option value=\"\">{placeholder}</option>\n {/if}\n {#each options as option}\n <option value={option.value} disabled={option.disabled}>\n {option.label}\n </option>\n {/each}\n </select>\n {#if error}\n <p class=\"text-error-500\" style=\"font-size: var(--text-label)\">{error}</p>\n {/if}\n</div>\n",
|
|
136
|
+
"/lib/components/ui/form/form-shared.ts": "// Shared form types and constants\n\nexport type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';\n\n/** Error message styling — shared across all form field components */\nexport const ERROR_CLASSES = 'text-red-500 text-sm mt-1';\n",
|
|
137
|
+
"/lib/components/ui/form/FormField.svelte": "<script lang=\"ts\">\n import { type InputType } from './form-shared';\n import { cn } from '../utils/cn';\n\n interface Props {\n label: string;\n id: string;\n type?: InputType;\n value?: string | undefined;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n hint?: string;\n error?: string;\n class?: string;\n name?: string;\n onblur?: () => void;\n oninput?: (event: Event) => void;\n }\n\n let {\n label,\n id,\n type = 'text',\n value = $bindable('' as string | undefined),\n placeholder = '',\n required = false,\n disabled = false,\n hint,\n error = '',\n class: className = '',\n name,\n onblur,\n oninput\n }: Props = $props();\n<\/script>\n\n<div class={className}>\n <label for={id} class=\"label\">\n <span class=\"label-text flex justify-between items-center\" style=\"gap: var(--gap-lg)\">\n <span>\n {label}\n {#if hint}\n <span class=\"opacity-70\">{hint}</span>\n {/if}\n </span>\n {#if error}\n <span class=\"text-error-500 shrink-0\" style=\"font-size: var(--text-caption); font-weight: var(--weight-label)\">{error}</span>\n {/if}\n </span>\n <input\n {id}\n {type}\n {name}\n bind:value\n {placeholder}\n {required}\n {disabled}\n {onblur}\n {oninput}\n class=\"input {error ? 'border-error-500' : ''}\"\n />\n </label>\n</div>\n",
|
|
138
|
+
"/lib/components/ui/form/TextArea.svelte": "<script lang=\"ts\">\n import { cn } from '../utils/cn';\n\n interface Props {\n id?: string;\n name?: string;\n value?: string;\n placeholder?: string;\n required?: boolean;\n disabled?: boolean;\n error?: boolean;\n rows?: number;\n maxlength?: number;\n class?: string;\n }\n\n let {\n id,\n name,\n value = $bindable(''),\n placeholder = '',\n required = false,\n disabled = false,\n error = false,\n rows = 3,\n maxlength,\n class: className = ''\n }: Props = $props();\n\n const normalClass = 'textarea';\n\n const errorClass =\n 'textarea border-error-500 text-error-900-100 placeholder:text-error-400-500';\n\n const textareaClass = $derived(cn(error ? errorClass : normalClass, className));\n<\/script>\n\n<textarea\n {id}\n {name}\n bind:value\n {placeholder}\n {required}\n {disabled}\n {rows}\n {maxlength}\n class={textareaClass}\n></textarea>\n",
|
|
139
|
+
"/lib/components/icons/Icon.svelte": "<script lang=\"ts\">\n import {\n Airplane,\n ArrowClockwise,\n ArrowCounterClockwise,\n ArrowLeft,\n ArrowSquareOut,\n ArrowsClockwise,\n ArrowsDownUp,\n Bell,\n BellRinging,\n Briefcase,\n Calendar,\n Car,\n CaretDown,\n CaretLeft,\n CaretLineLeft,\n CaretLineRight,\n CaretRight,\n ChatCircle,\n Check,\n CheckCircle,\n Clock,\n Coffee,\n Code,\n Copy,\n Crown,\n EnvelopeOpen,\n EnvelopeSimple,\n Eye,\n EyeSlash,\n File,\n FileText,\n FloppyDisk,\n Funnel,\n Gear,\n Gift,\n GitPullRequest,\n Hash,\n Image,\n Tray,\n Info,\n Key,\n List,\n ListNumbers,\n MapPin,\n Medal,\n Hamburger,\n Minus,\n Moon,\n Package,\n PaperPlaneTilt,\n PauseCircle,\n Pencil,\n Phone,\n Play,\n PlayCircle,\n Plus,\n Power,\n Quotes,\n Sailboat,\n MagnifyingGlass,\n SealCheck,\n ShareNetwork,\n Shield,\n ShieldCheck,\n ShieldWarning,\n ShoppingCart,\n SignOut,\n Spinner,\n SquaresFour,\n Star,\n Sun,\n TextB,\n TextItalic,\n TextStrikethrough,\n TextUnderline,\n Trash,\n Trophy,\n User,\n UserCheck,\n UserMinus,\n UserPlus,\n Users,\n ForkKnife,\n Warning,\n WarningCircle,\n Wine,\n X,\n XCircle\n } from 'phosphor-svelte';\n\n interface Props {\n name: string;\n size?: number | string;\n color?: string;\n class?: string;\n title?: string;\n }\n\n let { name, size = 16, color, class: className, title }: Props = $props();\n\n const iconMap: Record<string, typeof Clock> = {\n award: Medal,\n circleCheck: CheckCircle,\n clock: Clock,\n circleX: XCircle,\n plane: Airplane,\n car: Car,\n sailboat: Sailboat,\n mapPin: MapPin,\n suitcase: Briefcase,\n briefcase: Briefcase,\n utensils: ForkKnife,\n coffee: Coffee,\n wine: Wine,\n trophy: Trophy,\n medal: Medal,\n star: Star,\n shield: Shield,\n crown: Crown,\n refresh: ArrowsClockwise,\n refreshCw: ArrowsClockwise,\n save: FloppyDisk,\n bold: TextB,\n italic: TextItalic,\n list: List,\n users: Users,\n mail: EnvelopeSimple,\n mailCheck: SealCheck,\n mailOpen: EnvelopeOpen,\n file: File,\n fileText: FileText,\n settings: Gear,\n eye: Eye,\n search: MagnifyingGlass,\n send: PaperPlaneTilt,\n plus: Plus,\n package: Package,\n edit: Pencil,\n pencil: Pencil,\n trash2: Trash,\n 'trash-2': Trash,\n trash: Trash,\n power: Power,\n checkCircle2: CheckCircle,\n checkCircle: CheckCircle,\n 'check-circle': CheckCircle,\n xCircle: XCircle,\n 'x-circle': XCircle,\n userCheck: UserCheck,\n 'user-check': UserCheck,\n userMinus: UserMinus,\n 'user-minus': UserMinus,\n userX: UserMinus,\n 'user-x': UserMinus,\n userPlus: UserPlus,\n 'user-plus': UserPlus,\n calendar: Calendar,\n user: User,\n arrowLeft: ArrowLeft,\n arrowUpDown: ArrowsDownUp,\n gift: Gift,\n hash: Hash,\n phone: Phone,\n alertCircle: WarningCircle,\n 'alert-circle': WarningCircle,\n alertTriangle: Warning,\n clipboardCopy: Copy,\n pauseCircle: PauseCircle,\n play: Play,\n playCircle: PlayCircle,\n messageCircle: ChatCircle,\n logout: SignOut,\n menu: Hamburger,\n filter: Funnel,\n sun: Sun,\n moon: Moon,\n externalLink: ArrowSquareOut,\n inbox: Tray,\n image: Image,\n info: Info,\n share2: ShareNetwork,\n shoppingCart: ShoppingCart,\n x: X,\n check: Check,\n gitPullRequest: GitPullRequest,\n layoutDashboard: SquaresFour,\n chevronLeft: CaretLeft,\n chevronRight: CaretRight,\n chevronsLeft: CaretLineLeft,\n chevronsRight: CaretLineRight,\n powerOff: Power,\n chevronDown: CaretDown,\n key: Key,\n loader2: Spinner,\n eyeOff: EyeSlash,\n shieldCheck: ShieldCheck,\n shieldX: ShieldWarning,\n underline: TextUnderline,\n strikethrough: TextStrikethrough,\n listOrdered: ListNumbers,\n 'list-ordered': ListNumbers,\n quote: Quotes,\n code: Code,\n minus: Minus,\n undo2: ArrowCounterClockwise,\n redo2: ArrowClockwise,\n bell: Bell,\n bellRinging: BellRinging\n };\n\n const IconComponent = $derived(iconMap[name] ?? iconMap.circleCheck);\n<\/script>\n\n<div class=\"inline-flex items-center justify-center {className}\" {title}>\n <IconComponent {size} {color} />\n</div>\n",
|
|
140
|
+
"/lib/components/layout/auth-buttons.svelte": "<script lang=\"ts\">\n import { enhance } from '$app/forms';\n import { page } from '$app/stores';\n import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n\n interface Props {\n class?: string;\n style?: string;\n user?: { id: string; name?: string; email: string; role?: string; image?: string } | null;\n }\n\n let { class: className = '', style: styleAttr, user = null }: Props = $props();\n\n const isLoggedIn = $derived(!!user);\n const isAdmin = $derived(user?.role === 'admin');\n const showLogout = $derived(\n $page.url.pathname === '/dashboard' || $page.url.pathname.startsWith('/admin')\n );\n const hideDashboardLink = $derived($page.url.pathname === '/dashboard');\n<\/script>\n\n<div class=\"flex items-center {className}\" style=\"gap: var(--gap-sm); {styleAttr || ''}\">\n {#if isLoggedIn}\n {#if isAdmin}\n <a href=\"/admin\" class=\"btn-icon text-surface-50-950 hover:bg-surface-200-800\" aria-label=\"Admin\">\n <Icon name=\"shield\" size={16} />\n </a>\n {/if}\n\n {#if !hideDashboardLink}\n <a href=\"/dashboard\" class=\"btn-icon text-surface-50-950 hover:bg-surface-200-800\" aria-label=\"Dashboard\">\n <Icon name=\"user\" size={16} />\n </a>\n {/if}\n\n {#if showLogout}\n <form action=\"/logout\" method=\"POST\" use:enhance class=\"contents\">\n <button type=\"submit\" class=\"btn-icon text-surface-50-950 hover:bg-surface-200-800 hover:text-error-500\" aria-label=\"Sign Out\">\n <Icon name=\"logout\" size={16} />\n </button>\n </form>\n {/if}\n\n <div class=\"w-px h-6 bg-surface-300-700 mx-1\"></div>\n {:else}\n <a href=\"/login\" class=\"btn-icon text-surface-50-950 hover:bg-surface-200-800 text-sm\">Sign In</a>\n <a href=\"/signup\" class=\"btn btn-sm preset-filled-primary-500\">Sign Up</a>\n {/if}\n\n <ThemeToggle />\n</div>\n",
|
|
141
|
+
"/lib/components/layout/index.ts": "export { default as AdminSidebar } from './AdminSidebar.svelte';\nexport { default as Footer } from './footer.svelte';\nexport { default as Navbar } from './navbar.svelte';\n",
|
|
142
|
+
"/lib/components/layout/AdminSidebar.svelte": "<script lang=\"ts\">\n import { enhance } from '$app/forms';\n import { page } from '$app/state';\n import Icon from '$lib/components/icons/Icon.svelte';\n import Avatar from '$lib/components/ui/Avatar.svelte';\n import Button from '$lib/components/ui/Button.svelte';\n import Divider from '$lib/components/ui/Divider.svelte';\n import Sheet from '$lib/components/ui/Sheet.svelte';\n import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';\n import Tooltip from '$lib/components/ui/Tooltip.svelte';\n import { cn } from '$lib/components/ui/utils/cn';\n\n interface NavItem {\n label: string;\n href: string;\n icon: string;\n }\n\n interface Props {\n user: {\n id: string;\n name?: string;\n email: string;\n image?: string | null;\n role?: string | null;\n };\n }\n\n let { user }: Props = $props();\n let collapsed = $state(false);\n let mobileOpen = $state(false);\n\n const navItems: NavItem[] = [\n { label: 'Dashboard', href: '/admin', icon: 'layoutDashboard' },\n { label: 'Users', href: '/admin/users', icon: 'users' },\n { label: 'Activity', href: '/admin/activity', icon: 'clock' },\n { label: 'Notifications', href: '/admin/notifications', icon: 'bellRinging' },\n { label: 'Settings', href: '/admin/settings', icon: 'settings' }\n ];\n\n function isActive(href: string): boolean {\n if (href === '/admin') {\n return page.url.pathname === '/admin';\n }\n return page.url.pathname.startsWith(href);\n }\n\n function closeMobile() {\n mobileOpen = false;\n }\n\n const displayName = $derived(user.name ?? user.email);\n const initials = $derived(\n (user.name ?? user.email)\n .split(' ')\n .map((part: string) => part[0])\n .slice(0, 2)\n .join('')\n .toUpperCase()\n );\n<\/script>\n\n<!-- Mobile sidebar (Sheet) -->\n<div class=\"lg:hidden\">\n <!-- Mobile top bar -->\n <div class=\"flex items-center justify-between p-4 bg-surface-100-900 border-b border-surface-200-700\">\n <span class=\"font-bold text-primary-400-500\" style=\"font-size: var(--text-logo)\">Admin</span>\n <button\n onclick={() => (mobileOpen = true)}\n class=\"btn-icon text-surface-50-950\"\n aria-label=\"Open admin menu\"\n >\n <Icon name=\"menu\" size={24} />\n </button>\n </div>\n\n <Sheet open={mobileOpen} side=\"left\" title=\"Admin\" class=\"bg-surface-100-900\">\n <nav class=\"flex flex-col gap-1\">\n {#each navItems as item}\n <a\n href={item.href}\n onclick={closeMobile}\n class={cn(\n 'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',\n isActive(item.href)\n ? 'bg-primary-500/10 text-primary-700-300'\n : 'text-surface-600-400 hover:bg-surface-200-800 hover:text-surface-900-50'\n )}\n >\n <Icon name={item.icon} size={20} />\n <span>{item.label}</span>\n </a>\n {/each}\n </nav>\n\n <div class=\"mt-auto pt-4\">\n <Divider />\n <div class=\"flex items-center gap-3 pt-4\">\n <Avatar src={user.image} alt={displayName} size=\"sm\" />\n <div class=\"flex flex-col min-w-0\">\n <span class=\"text-sm font-medium text-surface-50-950 truncate\">{displayName}</span>\n <span class=\"text-xs text-surface-500 truncate\">{user.email}</span>\n </div>\n </div>\n <div class=\"mt-3 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-600-400 hover:bg-surface-200-800 transition-colors\">\n <ThemeToggle />\n <span>Theme</span>\n </div>\n <form method=\"POST\" action=\"/logout\" use:enhance class=\"contents\">\n <button\n type=\"submit\"\n class=\"flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-error-500 hover:bg-error-500/10 transition-colors\"\n >\n <Icon name=\"logout\" size={16} />\n <span>Sign out</span>\n </button>\n </form>\n </div>\n </Sheet>\n</div>\n\n<!-- Desktop sidebar -->\n<aside\n class={cn(\n 'hidden lg:flex flex-col border-r border-surface-200-700 bg-surface-100-900 h-screen sticky top-0 shrink-0 transition-[width] duration-200',\n collapsed ? 'w-[4.5rem]' : 'w-60'\n )}\n>\n <!-- Sidebar header -->\n <div class=\"flex items-center justify-between p-4 border-b border-surface-200-700\">\n {#if !collapsed}\n <span class=\"font-bold text-primary-400-500 whitespace-nowrap\" style=\"font-size: var(--text-logo)\">\n Admin\n </span>\n {/if}\n <button\n onclick={() => (collapsed = !collapsed)}\n class={cn(\n 'btn-icon text-surface-600-400 hover:text-surface-50-950 hover:bg-surface-200-800 transition-colors',\n !collapsed && 'ml-auto'\n )}\n aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}\n >\n <Icon name={collapsed ? 'chevronRight' : 'chevronLeft'} size={18} />\n </button>\n </div>\n\n <!-- Navigation -->\n <nav class=\"flex-1 overflow-y-auto p-3 flex flex-col gap-1\">\n {#each navItems as item}\n {#if collapsed}\n <Tooltip content={item.label} side=\"right\">\n <a\n href={item.href}\n class={cn(\n 'flex items-center justify-center rounded-lg p-2.5 transition-colors',\n isActive(item.href)\n ? 'bg-primary-500/10 text-primary-700-300'\n : 'text-surface-600-400 hover:bg-surface-200-800 hover:text-surface-900-50'\n )}\n >\n <Icon name={item.icon} size={20} />\n </a>\n </Tooltip>\n {:else}\n <a\n href={item.href}\n class={cn(\n 'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',\n isActive(item.href)\n ? 'bg-primary-500/10 text-primary-700-300'\n : 'text-surface-600-400 hover:bg-surface-200-800 hover:text-surface-900-50'\n )}\n >\n <Icon name={item.icon} size={20} />\n <span>{item.label}</span>\n </a>\n {/if}\n {/each}\n </nav>\n\n <!-- Bottom section: user info + logout -->\n <div class=\"border-t border-surface-200-700 p-3\">\n {#if collapsed}\n <div class=\"flex flex-col items-center gap-2\">\n <Tooltip content={displayName} side=\"right\">\n <Avatar src={user.image} alt={displayName} size=\"sm\" />\n </Tooltip>\n <Tooltip content=\"Toggle theme\" side=\"right\">\n <ThemeToggle />\n </Tooltip>\n <Tooltip content=\"Sign out\" side=\"right\">\n <form method=\"POST\" action=\"/logout\" use:enhance class=\"contents\">\n <button\n type=\"submit\"\n class=\"flex items-center justify-center rounded-lg p-2 text-error-500 hover:bg-error-500/10 transition-colors\"\n >\n <Icon name=\"logout\" size={16} />\n </button>\n </form>\n </Tooltip>\n </div>\n {:else}\n <div class=\"flex items-center gap-3\">\n <Avatar src={user.image} alt={displayName} size=\"sm\" />\n <div class=\"flex flex-col min-w-0 flex-1\">\n <span class=\"text-sm font-medium text-surface-50-950 truncate\">{displayName}</span>\n <span class=\"text-xs text-surface-500 truncate\">{user.email}</span>\n </div>\n </div>\n <div class=\"mt-2 flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-surface-600-400 hover:bg-surface-200-800 transition-colors\">\n <ThemeToggle />\n <span>Theme</span>\n </div>\n <form method=\"POST\" action=\"/logout\" use:enhance class=\"contents\">\n <button\n type=\"submit\"\n class=\"flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-error-500 hover:bg-error-500/10 transition-colors\"\n >\n <Icon name=\"logout\" size={16} />\n <span>Sign out</span>\n </button>\n </form>\n {/if}\n </div>\n</aside>\n",
|
|
143
|
+
"/lib/components/layout/nav-links.svelte": "<script lang=\"ts\">\n interface Props {\n class?: string;\n user?: { id: string; name?: string; email: string; role?: string; image?: string } | null;\n onMobileItemClick?: () => void;\n }\n\n let { class: className = '', user = null, onMobileItemClick }: Props = $props();\n\n const isLoggedIn = $derived(!!user);\n const isAdmin = $derived(user?.role === 'admin');\n\n function handleNavClick() {\n onMobileItemClick?.();\n }\n<\/script>\n\n{#if isLoggedIn}\n {#if isAdmin}\n <a href=\"/admin\" onclick={handleNavClick} class={className}>Admin</a>\n <a href=\"/dashboard\" onclick={handleNavClick} class={className}>Dashboard</a>\n {:else}\n <a href=\"/dashboard\" onclick={handleNavClick} class={className}>Dashboard</a>\n {/if}\n{/if}\n",
|
|
144
|
+
"/lib/components/layout/mobile-menu.svelte": "<script lang=\"ts\">\n import { enhance } from '$app/forms';\n import { page } from '$app/stores';\n import { themeStore } from '$lib/utils/theme.svelte';\n\n interface Props {\n user?: { id: string; name?: string; email: string; role?: string; image?: string } | null;\n onClose: () => void;\n open: boolean;\n }\n\n let { user = null, onClose, open }: Props = $props();\n\n const showLogout = $derived(\n $page.url.pathname === '/dashboard' || $page.url.pathname.startsWith('/admin')\n );\n const isLoggedIn = $derived(!!user);\n const isAdmin = $derived(user?.role === 'admin');\n\n function handleThemeToggle() {\n themeStore.toggle();\n onClose();\n }\n\n function handleBackdropClick(e: MouseEvent) {\n if (e.target === e.currentTarget) onClose();\n }\n\n function handleKeydown(e: KeyboardEvent) {\n if (e.key === 'Escape') onClose();\n }\n<\/script>\n\n{#if open}\n <div\n class=\"md:hidden fixed inset-0 top-16 bg-surface-50-900 z-40\"\n onclick={handleBackdropClick}\n onkeydown={handleKeydown}\n role=\"dialog\"\n aria-modal=\"true\"\n tabindex=\"-1\"\n >\n <div class=\"flex flex-col p-6\">\n {#if isLoggedIn}\n <div class=\"mb-6 p-4 rounded-xl bg-surface-100-800\">\n <p class=\"font-medium text-sm\">{user?.name ?? ''}</p>\n <p class=\"text-xs text-surface-500\">{user?.email ?? ''}</p>\n </div>\n\n {#if isAdmin}\n <a href=\"/admin\" onclick={onClose} class=\"block px-4 py-3 rounded-xl hover:bg-surface-200-800 text-sm\">\n Admin\n </a>\n {/if}\n <a href=\"/dashboard\" onclick={onClose} class=\"block px-4 py-3 rounded-xl hover:bg-surface-200-800 text-sm\">\n Dashboard\n </a>\n\n {#if showLogout}\n <form action=\"/logout\" method=\"POST\" use:enhance class=\"contents\">\n <button type=\"submit\" class=\"flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-error-500/10 text-sm text-error-500 border-t border-surface-300-700 pt-6 w-full text-left\">\n Sign Out\n </button>\n </form>\n {/if}\n {:else}\n <a href=\"/login\" onclick={onClose} class=\"block px-4 py-3 rounded-xl hover:bg-surface-200-800 text-sm\">\n Sign In\n </a>\n <a href=\"/signup\" onclick={onClose} class=\"flex items-center justify-center px-4 py-3 rounded-xl bg-primary-500 text-surface-50 hover:bg-primary-600 mt-3 text-sm\">\n Sign Up\n </a>\n {/if}\n\n <button\n type=\"button\"\n onclick={handleThemeToggle}\n class=\"flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-surface-200-800 text-sm border-t border-surface-300-700 pt-6\"\n >\n {#if themeStore.isDark}\n Light Mode\n {:else}\n Dark Mode\n {/if}\n </button>\n </div>\n </div>\n{/if}\n",
|
|
145
|
+
"/lib/components/layout/footer.svelte": "<script lang=\"ts\">\n const currentYear = new Date().getFullYear();\n<\/script>\n\n<footer\n class=\"bg-surface-50-950 border-t border-surface-200-800\"\n>\n <div class=\"mx-auto\" style=\"max-width: var(--max-w-footer); padding: var(--footer-py) var(--footer-px)\">\n <div class=\"flex flex-col md:flex-row justify-between items-center\" style=\"gap: var(--gap-xl)\">\n <div class=\"flex flex-col items-center md:items-start\" style=\"gap: var(--gap-lg)\">\n <a\n href=\"/\"\n class=\"text-surface-900-100 hover:text-primary-600-400 transition-colors\"\n style=\"font-size: var(--text-logo); font-weight: var(--weight-title)\"\n >\n SvelteForge\n </a>\n <p class=\"text-surface-600-400 max-w-xs text-center md:text-left\" style=\"font-size: var(--text-body)\">\n A production-ready SvelteKit boilerplate with BetterAuth, Drizzle ORM, and Skeleton UI.\n </p>\n </div>\n\n <div class=\"flex flex-col items-center md:items-end\" style=\"gap: var(--gap-lg)\">\n <div\n class=\"text-surface-500 flex flex-col items-center md:items-end\"\n style=\"font-size: var(--text-caption); gap: var(--gap-xs)\"\n >\n <p>© {currentYear} SvelteForge. All rights reserved.</p>\n </div>\n </div>\n </div>\n </div>\n</footer>\n",
|
|
146
|
+
"/lib/components/layout/navbar.svelte": "<script lang=\"ts\">\n import { AppBar } from '@skeletonlabs/skeleton-svelte';\n import AuthButtons from './auth-buttons.svelte';\n import { themeStore } from '$lib/utils/theme.svelte';\n import { onMount, onDestroy } from 'svelte';\n import MobileMenu from './mobile-menu.svelte';\n import Icon from '$lib/components/icons/Icon.svelte';\n import Sheet from '$lib/components/ui/Sheet.svelte';\n import NotificationBadge from '$lib/components/ui/NotificationBadge.svelte';\n import EmptyState from '$lib/components/ui/EmptyState.svelte';\n import {\n getNotifications,\n getUnreadCount,\n markAsRead,\n markAllRead,\n fetchNotifications,\n timeAgo\n } from '$lib/stores/notification-store.svelte';\n\n interface Props {\n user?: { id: string; name?: string; email: string; role?: string; image?: string } | null;\n }\n\n let { user = null }: Props = $props();\n let mobileMenuOpen = $state(false);\n let notifOpen = $state(false);\n\n // Initialize notifications on mount\n onMount(() => {\n themeStore.init();\n if (user) {\n fetchNotifications();\n }\n });\n\n onDestroy(() => {\n themeStore.destroy();\n });\n\n function closeMobileMenu() {\n mobileMenuOpen = false;\n }\n\n function handleMarkAllRead() {\n markAllRead();\n }\n\n function handleNotificationClick(id: string) {\n markAsRead(id);\n }\n<\/script>\n\n<MobileMenu {user} onClose={closeMobileMenu} open={mobileMenuOpen} />\n\n<AppBar>\n <AppBar.Toolbar class=\"grid-cols-[1fr_auto_1fr]\">\n <AppBar.Lead>\n <a href=\"/\" class=\"text-primary-400-500 hover:text-primary-300-600 transition-colors font-bold\" style=\"font-size: var(--text-logo); font-weight: var(--weight-title)\">\n SvelteForge\n </a>\n </AppBar.Lead>\n\n <AppBar.Headline>\n <!-- Center: empty or breadcrumb -->\n </AppBar.Headline>\n\n <AppBar.Trail>\n <!-- Notification Bell (only for logged-in users) -->\n {#if user}\n <div class=\"relative\">\n <button\n onclick={() => (notifOpen = !notifOpen)}\n class=\"btn-icon text-surface-50-950 hover:bg-surface-200-800 transition-colors relative\"\n aria-label=\"Notifications\"\n >\n <Icon name=\"bell\" size={20} />\n {#if getUnreadCount() > 0}\n <NotificationBadge count={getUnreadCount()} />\n {/if}\n </button>\n </div>\n {/if}\n\n <AuthButtons {user} class=\"hidden md:flex items-center\" />\n\n <!-- Mobile menu toggle -->\n <button\n onclick={() => (mobileMenuOpen = !mobileMenuOpen)}\n class=\"md:hidden btn-icon text-surface-50-950\"\n aria-label=\"Menu\"\n >\n {#if mobileMenuOpen}\n <Icon name=\"x\" size={24} />\n {:else}\n <Icon name=\"menu\" size={24} />\n {/if}\n </button>\n </AppBar.Trail>\n </AppBar.Toolbar>\n</AppBar>\n\n<!-- Notification Sheet -->\n{#if user}\n <Sheet open={notifOpen} side=\"right\" title=\"Notifications\" class=\"bg-surface-50-950\">\n <div class=\"flex flex-col gap-4\">\n <!-- Actions -->\n {#if getUnreadCount() > 0}\n <div class=\"flex items-center justify-between\">\n <span class=\"text-sm text-surface-500\">\n {getUnreadCount()} unread\n </span>\n <button\n onclick={handleMarkAllRead}\n class=\"text-sm text-primary-500 hover:text-primary-600-400 transition-colors font-medium\"\n >\n Mark all read\n </button>\n </div>\n {/if}\n\n <!-- Notification List -->\n {#if getNotifications().length === 0}\n <EmptyState\n icon=\"bell\"\n title=\"No notifications\"\n description=\"You're all caught up! New notifications will appear here.\"\n />\n {:else}\n <div class=\"flex flex-col divide-y divide-surface-200-700\">\n {#each getNotifications() as notif (notif.id)}\n <button\n class=\"flex flex-col gap-1 text-left w-full py-3 px-1 transition-colors hover:bg-surface-100-900 relative\n {!notif.read ? 'border-l-2 border-l-primary-500 pl-2' : 'pl-3'}\"\n onclick={() => handleNotificationClick(notif.id)}\n >\n <div class=\"flex items-center justify-between gap-2\">\n <span class=\"text-sm font-medium {notif.read ? 'text-surface-600-400' : 'text-surface-50-950'}\">\n {notif.title}\n </span>\n {#if !notif.read}\n <span class=\"w-2 h-2 rounded-full bg-primary-500 shrink-0\"></span>\n {/if}\n </div>\n <p class=\"text-xs text-surface-500 line-clamp-2\">\n {notif.message}\n </p>\n <span class=\"text-xs text-surface-400-500 mt-0.5\">\n {timeAgo(notif.createdAt)}\n </span>\n </button>\n {/each}\n </div>\n {/if}\n </div>\n </Sheet>\n{/if}\n",
|
|
147
|
+
"/lib/types.ts": "/**\n * types.ts - Common types shared by all services\n *\n * Centralizes types to avoid duplication\n * and maintain a consistent architecture.\n */\n\n// ============================================================================\n// USER TYPES\n// ============================================================================\n\n/**\n * Options for paginated user queries\n */\nexport interface UserQueryOptions {\n page?: number;\n limit?: number;\n search?: string;\n sortBy?: string;\n sortOrder?: 'asc' | 'desc';\n filter?: string;\n}\n\n/**\n * Paginated result\n */\nexport interface PaginatedResult<T> {\n data: T[];\n total: number;\n page: number;\n limit: number;\n totalPages: number;\n}\n\n/**\n * Data to update for a user\n */\nexport interface UpdateUserData {\n name?: string;\n email?: string;\n image?: string;\n}\n\n// ============================================================================\n// ADMIN USER TYPES\n// ============================================================================\n\nexport interface UpdateUserAsAdminData {\n name?: string;\n email?: string;\n}\n",
|
|
148
|
+
"/app.css": "@import './lib/styles/fonts.css';\n\n@import 'tailwindcss';\n@plugin '@tailwindcss/forms';\n@plugin '@tailwindcss/typography';\n\n/* Dark mode strategy: data attribute (Skeleton native) */\n@custom-variant dark (&:where([data-mode=\"dark\"], [data-mode=\"dark\"] *));\n\n@import '@skeletonlabs/skeleton';\n@import '@skeletonlabs/skeleton-svelte';\n@import './lib/styles/svelteForge.css';\n@import './lib/styles/tokens.css';\n",
|
|
149
|
+
"/app.html": "<!doctype html>\n<html lang=\"en\" data-theme=\"svelteForge\">\n <head>\n <meta charset=\"utf-8\" />\n <link rel=\"icon\" href=\"%sveltekit.assets%/favicon.ico\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n %sveltekit.head%\n </head>\n <body data-sveltekit-preload-data=\"hover\">\n <div style=\"display: contents\">%sveltekit.body%</div>\n </body>\n</html>\n",
|
|
150
|
+
"/app.d.ts": "import type { Auth } from 'better-auth';\nimport type pino from 'pino';\n\ndeclare global {\n namespace App {\n interface Locals {\n auth: Auth;\n logger: pino.Logger;\n responseStatus?: number;\n rateLimit?: {\n limit: number;\n remaining: number;\n reset: Date;\n };\n session: {\n id: string;\n expiresAt: Date;\n ipAddress?: string;\n userAgent?: string;\n } | null;\n user: {\n id: string;\n email: string;\n emailVerified: boolean;\n name: string;\n image?: string | null;\n role?: string | null;\n banned?: boolean | null;\n banReason?: string | null;\n banExpires?: Date | null;\n } | null;\n }\n }\n}\n\ndeclare module 'better-auth' {\n interface Session {\n user: {\n id: string;\n createdAt: Date;\n updatedAt: Date;\n email: string;\n emailVerified: boolean;\n name: string;\n image?: string | null;\n role?: string | null;\n banned?: boolean | null;\n banReason?: string | null;\n banExpires?: Date | null;\n };\n }\n}\n\nexport {};\n",
|
|
151
|
+
"/tests-setup.ts": "import '@testing-library/jest-dom/vitest';\n"
|
|
152
|
+
};
|
|
153
|
+
//#endregion
|
|
154
|
+
//#region src/modes/landing.ts
|
|
155
|
+
/**
|
|
156
|
+
* Apply Landing mode files via sv.file()
|
|
157
|
+
* Landing = UI base kit for building a landing page
|
|
158
|
+
*/
|
|
159
|
+
function applyLandingMode(sv, landingFiles, fullstackFiles, projectName) {
|
|
160
|
+
const sharedPaths = Object.entries(fullstackFiles).filter(([path]) => {
|
|
161
|
+
if (path.startsWith("/lib/components/ui/")) {
|
|
162
|
+
const name = path.split("/").pop() || "";
|
|
163
|
+
if ([
|
|
164
|
+
"AuthCard",
|
|
165
|
+
"DataTable",
|
|
166
|
+
"NavigationLoader",
|
|
167
|
+
"NotificationBadge",
|
|
168
|
+
"SearchInput",
|
|
169
|
+
".test.ts"
|
|
170
|
+
].some((s) => name.startsWith(s) || name.endsWith(s))) return false;
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
if (path.startsWith("/lib/components/layout/")) {
|
|
174
|
+
const name = path.split("/").pop() || "";
|
|
175
|
+
if (["auth-buttons", "AdminSidebar"].some((s) => name.startsWith(s))) return false;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (path.startsWith("/lib/components/icons/")) return true;
|
|
179
|
+
if (path === "/lib/components/index.ts") return true;
|
|
180
|
+
if (path.startsWith("/lib/components/layout/index.ts")) return true;
|
|
181
|
+
if (path.startsWith("/lib/components/ui/index.ts")) return true;
|
|
182
|
+
if (path.startsWith("/lib/components/ui/form/index.ts")) return true;
|
|
183
|
+
if (path.startsWith("/lib/styles/")) return true;
|
|
184
|
+
if (path.startsWith("/lib/utils/")) {
|
|
185
|
+
const name = path.split("/").pop() || "";
|
|
186
|
+
if (name.endsWith(".test.ts")) return false;
|
|
187
|
+
if ([
|
|
188
|
+
"export.ts",
|
|
189
|
+
"slugify.ts",
|
|
190
|
+
"form-errors.ts"
|
|
191
|
+
].includes(name)) return false;
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if ([
|
|
195
|
+
"/lib/errors.ts",
|
|
196
|
+
"/lib/logger.ts",
|
|
197
|
+
"/lib/types.ts",
|
|
198
|
+
"/lib/index.ts"
|
|
199
|
+
].includes(path)) return true;
|
|
200
|
+
if (path.startsWith("/lib/schemas/")) return true;
|
|
201
|
+
if (["/app.css", "/app.html"].includes(path)) return true;
|
|
202
|
+
if (path.startsWith("/routes/(legal)/")) return true;
|
|
203
|
+
if (path === "/routes/+error.svelte") return true;
|
|
204
|
+
return false;
|
|
205
|
+
});
|
|
206
|
+
for (const [path, content] of sharedPaths) sv.file(`src${path}`, () => content);
|
|
207
|
+
for (const [path, content] of Object.entries(landingFiles)) {
|
|
208
|
+
const finalContent = content.replace(/__PROJECT_NAME__/g, projectName);
|
|
209
|
+
sv.file(`src${path}`, () => finalContent);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
//#endregion
|
|
213
|
+
//#region src/modes/fullstack.ts
|
|
214
|
+
/**
|
|
215
|
+
* Apply Fullstack mode files via sv.file()
|
|
216
|
+
* Fullstack = UI + dashboard + auth + DB
|
|
217
|
+
*/
|
|
218
|
+
function applyFullstackMode(sv, files) {
|
|
219
|
+
sv.devDependency("@testing-library/jest-dom", "^6.9.1");
|
|
220
|
+
sv.devDependency("@testing-library/svelte", "^5.3.1");
|
|
221
|
+
sv.devDependency("jsdom", "^29.1.1");
|
|
222
|
+
sv.devDependency("vitest", "^4.1.5");
|
|
223
|
+
for (const [path, content] of Object.entries(files)) sv.file(`src${path}`, () => content);
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/index.ts
|
|
227
|
+
var src_default = defineAddon({
|
|
228
|
+
id: "svelteforge",
|
|
229
|
+
alias: "forge",
|
|
230
|
+
shortDescription: "SvelteForge — themed UI kit + layouts for SvelteKit",
|
|
231
|
+
homepage: "https://github.com/lelabdev/svelteforge",
|
|
232
|
+
options: defineAddonOptions().add("template", {
|
|
233
|
+
question: "Which SvelteForge template?",
|
|
234
|
+
type: "select",
|
|
235
|
+
options: [{
|
|
236
|
+
value: "landing",
|
|
237
|
+
label: "Landing Page — UI only"
|
|
238
|
+
}, {
|
|
239
|
+
value: "fullstack",
|
|
240
|
+
label: "Full Stack — dashboard + auth + DB"
|
|
241
|
+
}]
|
|
242
|
+
}).build(),
|
|
243
|
+
setup: ({ unsupported, isKit }) => {
|
|
244
|
+
if (!isKit) unsupported("SvelteForge requires SvelteKit");
|
|
245
|
+
},
|
|
246
|
+
run: ({ sv, options, file, directory }) => {
|
|
247
|
+
const template = options.template;
|
|
248
|
+
sv.dependency("@fontsource-variable/fira-code", "latest");
|
|
249
|
+
sv.dependency("@fontsource-variable/inter", "latest");
|
|
250
|
+
sv.dependency("@fontsource-variable/manrope", "latest");
|
|
251
|
+
sv.dependency("@fontsource-variable/space-grotesk", "latest");
|
|
252
|
+
sv.dependency("@tiptap/core", "latest");
|
|
253
|
+
sv.dependency("@tiptap/extension-underline", "latest");
|
|
254
|
+
sv.dependency("@tiptap/starter-kit", "latest");
|
|
255
|
+
sv.dependency("clsx", "latest");
|
|
256
|
+
sv.dependency("phosphor-svelte", "^3.1.0");
|
|
257
|
+
sv.dependency("pino", "latest");
|
|
258
|
+
sv.dependency("pino-pretty", "latest");
|
|
259
|
+
sv.dependency("sveltekit-superforms", "latest");
|
|
260
|
+
sv.dependency("tailwind-merge", "latest");
|
|
261
|
+
sv.dependency("zod", "latest");
|
|
262
|
+
sv.devDependency("@skeletonlabs/skeleton", "latest");
|
|
263
|
+
sv.devDependency("@skeletonlabs/skeleton-svelte", "latest");
|
|
264
|
+
if (template === "landing") applyLandingMode(sv, landingFiles, fullstackFiles, directory.src.split("/").slice(-2, -1)[0] || "My App");
|
|
265
|
+
else applyFullstackMode(sv, fullstackFiles);
|
|
266
|
+
},
|
|
267
|
+
nextSteps: ({ options }) => [`SvelteForge ${options.template} template applied!`, "Run `bun dev` to start developing."]
|
|
268
|
+
});
|
|
269
|
+
//#endregion
|
|
270
|
+
export { src_default as default };
|