initial production version

This commit is contained in:
2025-11-25 16:08:50 +01:00
parent 44ce6e38dd
commit 0144e8df1a
108 changed files with 5502 additions and 1780 deletions

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
import type { Snippet } from 'svelte';
import { getCardStyle } from '$lib/utils/colors';
let {
title,
description,
itemCount,
color = null,
children
}: {
title: string;
description?: string | null;
itemCount: number;
color?: string | null;
children?: Snippet;
} = $props();
const cardStyle = $derived(getCardStyle(color));
</script>
<Card style={cardStyle} class="h-full flex flex-col">
<CardHeader class="flex-shrink-0">
<div class="flex items-center justify-between gap-2">
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
<span class="truncate">{title}</span>
</CardTitle>
<span class="text-sm text-muted-foreground flex-shrink-0">
{itemCount} item{itemCount === 1 ? '' : 's'}
</span>
</div>
{#if description}
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
{/if}
</CardHeader>
<CardContent class="space-y-2 flex-1 flex flex-col justify-end">
{#if children}
<div>
{@render children()}
</div>
{/if}
</CardContent>
</Card>

View File

@@ -0,0 +1,79 @@
<script lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import EmptyState from '$lib/components/layout/EmptyState.svelte';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
let {
title,
description,
items,
emptyMessage,
emptyDescription,
emptyActionLabel,
emptyActionHref,
headerAction,
children
}: {
title: string;
description: string;
items: any[];
emptyMessage: string;
emptyDescription?: string;
emptyActionLabel?: string;
emptyActionHref?: string;
headerAction?: Snippet;
children: Snippet<[any]>;
} = $props();
let scrollContainer: HTMLElement | null = null;
function handleWheel(event: WheelEvent) {
if (!scrollContainer) return;
// Check if we have horizontal overflow
const hasHorizontalScroll = scrollContainer.scrollWidth > scrollContainer.clientWidth;
if (hasHorizontalScroll && event.deltaY !== 0) {
event.preventDefault();
scrollContainer.scrollLeft += event.deltaY;
}
}
</script>
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</div>
{#if headerAction}
{@render headerAction()}
{/if}
</div>
</CardHeader>
<CardContent>
{#if items && items.length > 0}
<div
bind:this={scrollContainer}
onwheel={handleWheel}
class="flex overflow-x-auto gap-4 pb-4 -mx-6 px-6"
>
{#each items as item (item.id)}
<div class="flex-shrink-0 w-80" animate:flip={{ duration: 300 }}>
{@render children(item)}
</div>
{/each}
</div>
{:else}
<EmptyState
message={emptyMessage}
description={emptyDescription}
actionLabel={emptyActionLabel}
actionHref={emptyActionHref}
/>
{/if}
</CardContent>
</Card>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { signOut } from '@auth/sveltekit/client';
let { userName, userEmail }: { userName?: string | null; userEmail?: string | null } = $props();
</script>
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Dashboard</h1>
<p class="text-muted-foreground">Welcome back, {userName || userEmail}</p>
</div>
<div class="flex items-center gap-2">
<ThemeToggle />
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>Sign Out</Button>
</div>
</div>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import type { Snippet } from 'svelte';
let {
message,
description,
actionLabel,
actionHref,
onclick,
children
}: {
message: string;
description?: string;
actionLabel?: string;
actionHref?: string;
onclick?: () => void;
children?: Snippet;
} = $props();
</script>
<div class="text-center py-8 text-muted-foreground">
<p class="text-base">{message}</p>
{#if description}
<p class="text-sm mt-2">{description}</p>
{/if}
{#if children}
<div class="mt-4">
{@render children()}
</div>
{:else if actionLabel}
<Button
class="mt-4"
onclick={() => {
if (onclick) {
onclick();
} else if (actionHref) {
window.location.href = actionHref;
}
}}
>
{actionLabel}
</Button>
{/if}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import { LayoutDashboard } from 'lucide-svelte';
import { languageStore } from '$lib/stores/language.svelte';
let {
isAuthenticated = false,
showDashboardLink = false
}: {
isAuthenticated?: boolean;
showDashboardLink?: boolean;
} = $props();
const t = $derived(languageStore.t);
</script>
<nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full">
{#if isAuthenticated}
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/dashboard')} class="px-2 sm:px-3">
<LayoutDashboard class="w-4 h-4" />
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
</Button>
{:else}
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')} class="px-2 sm:px-3">
{t.auth.signIn}
</Button>
{/if}
<div class="ml-auto flex items-center gap-1 sm:gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
</nav>

View File

@@ -0,0 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children, maxWidth = '6xl' }: { children: Snippet; maxWidth?: string } = $props();
</script>
<div class="min-h-screen p-4 md:p-8">
<div class="max-w-{maxWidth} mx-auto space-y-6">
{@render children()}
</div>
</div>

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { X, Pencil } from 'lucide-svelte';
let {
color = $bindable(null),
size = 'md',
onchange
}: {
color: string | null;
size?: 'sm' | 'md' | 'lg';
onchange?: () => void;
} = $props();
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12'
};
const iconSizeClasses = {
sm: 'w-3 h-3',
md: 'w-4 h-4',
lg: 'w-5 h-5'
};
const buttonSize = sizeClasses[size];
const iconSize = iconSizeClasses[size];
function handleColorChange(e: Event) {
color = (e.target as HTMLInputElement).value;
onchange?.();
}
function clearColor() {
color = null;
onchange?.();
}
</script>
<div class="flex items-center gap-2">
{#if color}
<button
type="button"
onclick={clearColor}
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
aria-label="Clear color"
>
<X class={iconSize} />
</button>
{/if}
<label
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
style={color ? `background-color: ${color};` : ''}
>
<Pencil class="{iconSize} relative z-10 pointer-events-none" style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''} />
<input
type="color"
value={color || '#ffffff'}
oninput={handleColorChange}
class="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
</div>

View File

@@ -0,0 +1,83 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,16 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant
};

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & {
children?: any;
};
let { class: className, children, ...restProps }: Props = $props();
</script>
<div class={cn('p-6 pt-0', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLParagraphElement> & {
children?: any;
};
let { class: className, children, ...restProps }: Props = $props();
</script>
<p class={cn('text-sm text-muted-foreground', className)} {...restProps}>
{@render children?.()}
</p>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & {
children?: any;
};
let { class: className, children, ...restProps }: Props = $props();
</script>
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}>
{@render children?.()}
</div>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLHeadingElement> & {
children?: any;
};
let { class: className, children, ...restProps }: Props = $props();
</script>
<h3 class={cn('font-semibold leading-none tracking-tight', className)} {...restProps}>
{@render children?.()}
</h3>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & {
children?: any;
};
let { class: className, children, ...restProps }: Props = $props();
</script>
<div
class={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
import Root from './card.svelte';
import Content from './card-content.svelte';
import Description from './card-description.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
export {
Root,
Content,
Description,
Header,
Title,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Header as CardHeader,
Title as CardTitle
};

View File

@@ -0,0 +1,3 @@
import Root from './input.svelte';
export { Root, Root as Input };

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLInputAttributes } from 'svelte/elements';
type Props = HTMLInputAttributes & {
value?: string | number;
};
let { class: className, type = 'text', value = $bindable(''), ...restProps }: Props = $props();
</script>
<input
type={type}
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...restProps}
/>

View File

@@ -0,0 +1,3 @@
import Root from './label.svelte';
export { Root, Root as Label };

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLLabelAttributes } from 'svelte/elements';
type Props = HTMLLabelAttributes & {
children?: any;
};
let { class: className, children, ...restProps }: Props = $props();
</script>
<label
class={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className
)}
{...restProps}
>
{@render children?.()}
</label>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { languageStore } from '$lib/stores/language.svelte';
import { languages } from '$lib/i18n/translations';
import { Button } from '$lib/components/ui/button';
import { Languages } from 'lucide-svelte';
let showMenu = $state(false);
function toggleMenu() {
showMenu = !showMenu;
}
function setLanguage(code: 'en' | 'da') {
languageStore.setLanguage(code);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.language-toggle-menu')) {
showMenu = false;
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="relative language-toggle-menu">
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Toggle language">
<Languages class="h-[1.2rem] w-[1.2rem]" />
</Button>
{#if showMenu}
<div
class="absolute right-0 mt-2 w-40 rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 shadow-lg z-50"
>
<div class="py-1">
{#each languages as lang}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
class:font-bold={languageStore.current === lang.code}
class:bg-slate-100={languageStore.current === lang.code}
class:dark:bg-slate-900={languageStore.current === lang.code}
onclick={() => setLanguage(lang.code)}
>
{lang.name}
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,2 @@
import LanguageToggle from './LanguageToggle.svelte';
export { LanguageToggle };

View File

@@ -0,0 +1,3 @@
import Root from './textarea.svelte';
export { Root, Root as Textarea };

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLTextareaAttributes } from 'svelte/elements';
type Props = HTMLTextareaAttributes & {
value?: string;
};
let { class: className, value = $bindable(''), ...restProps }: Props = $props();
</script>
<textarea
class={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...restProps}
></textarea>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { themeStore } from '$lib/stores/theme.svelte';
import { Button } from '$lib/components/ui/button';
import { Sun, Moon, Monitor } from 'lucide-svelte';
function toggle() {
themeStore.toggle();
}
</script>
<Button onclick={toggle} variant="ghost" size="icon" class="rounded-full">
{#if themeStore.current === 'light'}
<Sun size={20} />
<span class="sr-only">Light mode (click for dark)</span>
{:else if themeStore.current === 'dark'}
<Moon size={20} />
<span class="sr-only">Dark mode (click for system)</span>
{:else}
<Monitor size={20} />
<span class="sr-only">System mode (click for light)</span>
{/if}
</Button>

View File

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

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import ImageSelector from './ImageSelector.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { enhance } from '$app/forms';
interface Props {
onSuccess?: () => void;
}
let { onSuccess }: Props = $props();
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
let linkUrl = $state('');
let imageUrl = $state('');
let color = $state<string | null>(null);
let scrapedImages = $state<string[]>([]);
let isLoadingImages = $state(false);
async function handleLinkChange(event: Event) {
const input = event.target as HTMLInputElement;
linkUrl = input.value;
if (linkUrl && linkUrl.startsWith('http')) {
isLoadingImages = true;
scrapedImages = [];
try {
const response = await fetch('/api/scrape-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: linkUrl })
});
if (response.ok) {
const data = await response.json();
scrapedImages = data.images || [];
}
} catch (error) {
console.error('Failed to scrape images:', error);
} finally {
isLoadingImages = false;
}
}
}
</script>
<Card>
<CardHeader>
<CardTitle>Add New Item</CardTitle>
</CardHeader>
<CardContent>
<form
method="POST"
action="?/addItem"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
onSuccess?.();
};
}}
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 md:col-span-2">
<Label for="title">Item Name *</Label>
<Input id="title" name="title" required placeholder="e.g., Blue Headphones" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Add details about the item..."
rows={3}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="link">Link (URL)</Label>
<Input
id="link"
name="link"
type="url"
placeholder="https://..."
bind:value={linkUrl}
oninput={handleLinkChange}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="imageUrl">Image URL</Label>
<Input
id="imageUrl"
name="imageUrl"
type="url"
placeholder="https://..."
bind:value={imageUrl}
/>
<ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
</div>
<div class="space-y-2">
<Label for="price">Price</Label>
<Input id="price" name="price" type="number" step="0.01" placeholder="0.00" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="currency">Currency</Label>
<select
id="currency"
name="currency"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
>
{#each currencies as curr}
<option value={curr} selected={curr === 'DKK'}>{curr}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between">
<Label for="color">Card Color (optional)</Label>
<ColorPicker bind:color={color} />
</div>
<input type="hidden" name="color" value={color || ''} />
</div>
</div>
<Button type="submit" class="w-full md:w-auto">Add Item</Button>
</form>
</CardContent>
</Card>

View File

@@ -0,0 +1,176 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import ImageSelector from './ImageSelector.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { enhance } from '$app/forms';
import type { Item } from '$lib/server/schema';
interface Props {
item: Item;
onSuccess?: () => void;
onCancel?: () => void;
onColorChange?: (itemId: string, color: string) => void;
currentPosition?: number;
totalItems?: number;
onPositionChange?: (newPosition: number) => void;
}
let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange }: Props = $props();
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
let linkUrl = $state(item.link || '');
let imageUrl = $state(item.imageUrl || '');
let color = $state<string | null>(item.color);
let scrapedImages = $state<string[]>([]);
let isLoadingImages = $state(false);
async function handleLinkChange(event: Event) {
const input = event.target as HTMLInputElement;
linkUrl = input.value;
if (linkUrl && linkUrl.startsWith('http')) {
isLoadingImages = true;
scrapedImages = [];
try {
const response = await fetch('/api/scrape-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: linkUrl })
});
if (response.ok) {
const data = await response.json();
scrapedImages = data.images || [];
}
} catch (error) {
console.error('Failed to scrape images:', error);
} finally {
isLoadingImages = false;
}
}
}
</script>
<Card>
<CardHeader>
<CardTitle>Edit Item</CardTitle>
</CardHeader>
<CardContent>
<form
method="POST"
action="?/updateItem"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
onSuccess?.();
};
}}
class="space-y-4"
>
<input type="hidden" name="itemId" value={item.id} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 md:col-span-2">
<Label for="title">Item Name *</Label>
<Input id="title" name="title" required value={item.title} placeholder="e.g., Blue Headphones" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="description">Description</Label>
<Textarea
id="description"
name="description"
value={item.description || ''}
placeholder="Add details about the item..."
rows={3}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="link">Link (URL)</Label>
<Input
id="link"
name="link"
type="url"
placeholder="https://..."
bind:value={linkUrl}
oninput={handleLinkChange}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="imageUrl">Image URL</Label>
<Input
id="imageUrl"
name="imageUrl"
type="url"
placeholder="https://..."
bind:value={imageUrl}
/>
<ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
</div>
<div class="space-y-2">
<Label for="price">Price</Label>
<Input id="price" name="price" type="number" step="0.01" value={item.price || ''} placeholder="0.00" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="currency">Currency</Label>
<select
id="currency"
name="currency"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
>
{#each currencies as curr}
<option value={curr} selected={item.currency === curr}>{curr}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between">
<Label for="color">Card Color (optional)</Label>
<ColorPicker bind:color={color} onchange={() => onColorChange?.(item.id, color || '')} />
</div>
<input type="hidden" name="color" value={color || ''} />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="position">Position in List</Label>
<Input
id="position"
type="number"
min="1"
max={totalItems}
value={currentPosition}
onchange={(e) => {
const newPos = parseInt((e.target as HTMLInputElement).value);
if (newPos >= 1 && newPos <= totalItems) {
onPositionChange?.(newPos);
}
}}
placeholder="1"
/>
<p class="text-sm text-muted-foreground">
Choose where this item appears in your wishlist (1 = top, {totalItems} = bottom)
</p>
</div>
</div>
<div class="flex gap-2">
<Button type="submit" class="flex-1 md:flex-none">Save Changes</Button>
{#if onCancel}
<Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={onCancel}>Cancel</Button>
{/if}
</div>
</form>
</CardContent>
</Card>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
import EmptyState from "$lib/components/layout/EmptyState.svelte";
import type { Item } from "$lib/server/schema";
import { enhance } from "$app/forms";
import { flip } from "svelte/animate";
let {
items = $bindable([]),
rearranging,
onStartEditing,
onReorder
}: {
items: Item[];
rearranging: boolean;
onStartEditing: (item: Item) => void;
onReorder: (items: Item[]) => Promise<void>;
} = $props();
</script>
<div class="space-y-4">
{#if items && items.length > 0}
<div class="space-y-4">
{#each items as item (item.id)}
<div animate:flip={{ duration: 300 }}>
<WishlistItem {item} showDragHandle={false}>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onclick={() => onStartEditing(item)}
>
Edit
</Button>
{#if rearranging}
<form
method="POST"
action="?/deleteItem"
use:enhance
>
<input
type="hidden"
name="itemId"
value={item.id}
/>
<Button
type="submit"
variant="destructive"
size="sm"
>
Delete
</Button>
</form>
{/if}
</div>
</WishlistItem>
</div>
{/each}
</div>
{:else}
<Card>
<CardContent class="p-12">
<EmptyState
message="No items yet. Click 'Add Item' to get started!"
/>
</CardContent>
</Card>
{/if}
</div>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
let {
images,
selectedImage = $bindable(''),
isLoading = false
}: {
images: string[];
selectedImage?: string;
isLoading?: boolean;
} = $props();
</script>
{#if isLoading}
<p class="text-sm text-muted-foreground">Loading images...</p>
{:else if images.length > 0}
<div class="mt-2">
<Label class="text-sm">Or select from scraped images:</Label>
<div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2">
{#each images as imgUrl}
<button
type="button"
onclick={() => (selectedImage = imgUrl)}
class="relative aspect-square rounded-md overflow-hidden border-2 hover:border-primary transition-colors"
class:border-primary={selectedImage === imgUrl}
>
<img src={imgUrl} alt="" class="w-full h-full object-cover" />
</button>
{/each}
</div>
</div>
{/if}

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { enhance } from '$app/forms';
interface Props {
itemId: string;
isReserved: boolean;
reserverName?: string | null;
}
let { itemId, isReserved, reserverName }: Props = $props();
let showReserveForm = $state(false);
let name = $state('');
</script>
{#if isReserved}
<div class="flex flex-col items-end gap-2">
<div class="text-sm text-green-600 font-medium">
✓ Reserved
{#if reserverName}
by {reserverName}
{/if}
</div>
<form method="POST" action="?/unreserve" use:enhance>
<input type="hidden" name="itemId" value={itemId} />
<Button type="submit" variant="outline" size="sm">
Cancel Reservation
</Button>
</form>
</div>
{:else if showReserveForm}
<form
method="POST"
action="?/reserve"
use:enhance={() => {
return async ({ update }) => {
await update();
showReserveForm = false;
name = '';
};
}}
class="flex flex-col gap-2 w-full md:w-auto"
>
<input type="hidden" name="itemId" value={itemId} />
<Input
name="reserverName"
placeholder="Your name (optional)"
bind:value={name}
class="w-full md:w-48"
/>
<div class="flex gap-2">
<Button type="submit" size="sm" class="flex-1">Confirm</Button>
<Button
type="button"
variant="outline"
size="sm"
onclick={() => (showReserveForm = false)}
class="flex-1"
>
Cancel
</Button>
</div>
</form>
{:else}
<Button onclick={() => (showReserveForm = true)} size="sm" class="w-full md:w-auto">
Reserve This
</Button>
{/if}

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Card, CardContent } from '$lib/components/ui/card';
interface Props {
publicUrl: string;
ownerUrl?: string;
}
let { publicUrl, ownerUrl }: Props = $props();
let copiedPublic = $state(false);
let copiedOwner = $state(false);
const publicLink = $derived(
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
);
const ownerLink = $derived(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : '');
async function copyToClipboard(text: string, type: 'public' | 'owner') {
await navigator.clipboard.writeText(text);
if (type === 'public') {
copiedPublic = true;
setTimeout(() => (copiedPublic = false), 2000);
} else {
copiedOwner = true;
setTimeout(() => (copiedOwner = false), 2000);
}
}
</script>
<Card>
<CardContent class="space-y-4 pt-6">
<div class="space-y-2">
<Label>Share with friends (view only)</Label>
<div class="flex gap-2">
<Input readonly value={publicLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
{copiedPublic ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{#if ownerLink}
<div class="space-y-2">
<Label>Your edit link (keep this private!)</Label>
<div class="flex gap-2">
<Input readonly value={ownerLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
{copiedOwner ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>

View File

@@ -0,0 +1,33 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Lock, LockOpen } from "lucide-svelte";
import { enhance } from "$app/forms";
let {
rearranging = $bindable(false),
onToggleAddForm
}: {
rearranging: boolean;
onToggleAddForm: () => void;
} = $props();
let showAddForm = $state(false);
function toggleAddForm() {
showAddForm = !showAddForm;
onToggleAddForm();
}
function toggleRearranging() {
rearranging = !rearranging;
}
</script>
<div class="flex flex-col md:flex-row gap-4">
<Button
onclick={toggleAddForm}
class="w-full md:w-auto"
>
{showAddForm ? "Cancel" : "+ Add Item"}
</Button>
</div>

View File

@@ -0,0 +1,191 @@
<script lang="ts">
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import { Pencil, Check, X } from "lucide-svelte";
import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
import type { Wishlist } from "$lib/server/schema";
let {
wishlist,
onTitleUpdate,
onDescriptionUpdate,
onColorUpdate,
onEndDateUpdate
}: {
wishlist: Wishlist;
onTitleUpdate: (title: string) => Promise<boolean>;
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
onColorUpdate: (color: string | null) => void;
onEndDateUpdate: (endDate: string | null) => void;
} = $props();
let editingTitle = $state(false);
let editingDescription = $state(false);
let wishlistTitle = $state(wishlist.title);
let wishlistDescription = $state(wishlist.description || "");
let wishlistColor = $state<string | null>(wishlist.color);
let wishlistEndDate = $state<string | null>(
wishlist.endDate
? new Date(wishlist.endDate).toISOString().split("T")[0]
: null,
);
async function saveTitle() {
if (!wishlistTitle.trim()) {
wishlistTitle = wishlist.title;
editingTitle = false;
return;
}
const success = await onTitleUpdate(wishlistTitle.trim());
if (success) {
editingTitle = false;
} else {
wishlistTitle = wishlist.title;
editingTitle = false;
}
}
async function saveDescription() {
const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
if (success) {
editingDescription = false;
} else {
wishlistDescription = wishlist.description || "";
editingDescription = false;
}
}
function handleEndDateChange(e: Event) {
const input = e.target as HTMLInputElement;
wishlistEndDate = input.value || null;
onEndDateUpdate(wishlistEndDate);
}
function clearEndDate() {
wishlistEndDate = null;
onEndDateUpdate(null);
}
</script>
<!-- Title Header -->
<div class="flex items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-2 flex-1 min-w-0">
{#if editingTitle}
<Input
bind:value={wishlistTitle}
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
onkeydown={(e) => {
if (e.key === "Enter") {
saveTitle();
} else if (e.key === "Escape") {
wishlistTitle = wishlist.title;
editingTitle = false;
}
}}
autofocus
/>
{:else}
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
{/if}
<button
type="button"
onclick={() => {
if (editingTitle) {
saveTitle();
} else {
editingTitle = true;
}
}}
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
aria-label={editingTitle ? "Save title" : "Edit title"}
>
{#if editingTitle}
<Check class="w-4 h-4" />
{:else}
<Pencil class="w-4 h-4" />
{/if}
</button>
</div>
<div class="flex-shrink-0">
<ColorPicker
bind:color={wishlistColor}
onchange={() => onColorUpdate(wishlistColor)}
/>
</div>
</div>
<!-- Settings Card -->
<Card>
<CardContent class="pt-6 space-y-4">
<!-- Description -->
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="wishlist-description">Description (optional)</Label>
<button
type="button"
onclick={() => {
if (editingDescription) {
saveDescription();
} else {
editingDescription = true;
}
}}
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
aria-label={editingDescription ? "Save description" : "Edit description"}
>
{#if editingDescription}
<Check class="w-4 h-4" />
{:else}
<Pencil class="w-4 h-4" />
{/if}
</button>
</div>
{#if editingDescription}
<Textarea
id="wishlist-description"
bind:value={wishlistDescription}
class="w-full"
rows={3}
onkeydown={(e) => {
if (e.key === "Escape") {
wishlistDescription = wishlist.description || "";
editingDescription = false;
}
}}
autofocus
/>
{:else}
<div class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]">
{wishlistDescription || "No description"}
</div>
{/if}
</div>
<!-- End Date -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<Label for="wishlist-end-date">End Date (optional)</Label>
<div class="flex items-center gap-2">
{#if wishlistEndDate}
<button
type="button"
onclick={clearEndDate}
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
aria-label="Clear end date"
>
<X class="w-4 h-4" />
</button>
{/if}
<Input
id="wishlist-end-date"
type="date"
value={wishlistEndDate || ""}
onchange={handleEndDateChange}
class="w-full sm:w-auto"
/>
</div>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import { Card, CardContent } from "$lib/components/ui/card";
import type { Item } from "$lib/server/schema";
import { GripVertical, Link } from "lucide-svelte";
import { getCardStyle } from '$lib/utils/colors';
interface Props {
item: Item;
showImage?: boolean;
children?: any;
showDragHandle?: boolean;
}
let {
item,
showImage = true,
children,
showDragHandle = false,
}: Props = $props();
const currencySymbols: Record<string, string> = {
DKK: "kr",
EUR: "€",
USD: "$",
SEK: "kr",
NOK: "kr",
GBP: "£",
};
function formatPrice(
price: string | null,
currency: string | null,
): string {
if (!price) return "";
const symbol = currency ? currencySymbols[currency] || currency : "kr";
const amount = parseFloat(price).toFixed(2);
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && ["DKK", "SEK", "NOK"].includes(currency)) {
return `${amount} ${symbol}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}
const cardStyle = $derived(getCardStyle(item.color));
</script>
<Card style={cardStyle}>
<CardContent class="p-6">
<div class="flex gap-4">
{#if showDragHandle}
<div
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
aria-label="Drag to reorder"
role="button"
tabindex="0"
style="touch-action: none;"
>
<GripVertical class="w-6 h-6 text-muted-foreground" />
</div>
{/if}
<div class="flex flex-col md:flex-row gap-4 flex-1">
{#if showImage && item.imageUrl}
<img
src={item.imageUrl}
alt={item.title}
class="w-full md:w-32 h-32 object-cover rounded-lg"
/>
{/if}
<div class="flex-1 items-center">
<div
class="flex items-center justify-between flex-wrap"
>
<div class="flex-1">
<h3 class="font-semibold text-lg">{item.title}</h3>
</div>
{#if children}
{@render children()}
{/if}
</div>
{#if item.description}
<p class="text-muted-foreground">{item.description}</p>
{/if}
<div class="flex flex-wrap text-sm">
{#if item.price}
<span class="font-medium"
>{formatPrice(item.price, item.currency)}</span
>
{/if}
{#if item.link}
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
>
<div class="flex flex-row gap-1 items-center">
<p class="text-muted-foreground">View Product</p>
<Link
class="pt-1 w-5 h-5 text-muted-foreground"
/>
</div>
</a>
{/if}
</div>
</div>
</div>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,93 @@
import type { Translation } from './en';
// Danish translations - ADD YOUR TRANSLATIONS HERE
export const da: Translation = {
// Navigation
nav: {
dashboard: 'Dashboard' // TODO: Add Danish translation
},
// Dashboard
dashboard: {
myWishlists: 'My Wishlists', // TODO: Add Danish translation
myWishlistsDescription: 'Wishlists you own and manage', // TODO: Add Danish translation
savedWishlists: 'Saved Wishlists', // TODO: Add Danish translation
savedWishlistsDescription: "Wishlists you're following", // TODO: Add Danish translation
createNew: '+ Create New', // TODO: Add Danish translation
manage: 'Manage', // TODO: Add Danish translation
copyLink: 'Copy Link', // TODO: Add Danish translation
viewWishlist: 'View Wishlist', // TODO: Add Danish translation
unsave: 'Unsave', // TODO: Add Danish translation
emptyWishlists: "You haven't created any wishlists yet.", // TODO: Add Danish translation
emptyWishlistsAction: 'Create Your First Wishlist', // TODO: Add Danish translation
emptySavedWishlists: "You haven't saved any wishlists yet.", // TODO: Add Danish translation
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.", // TODO: Add Danish translation
by: 'by', // TODO: Add Danish translation
ends: 'Ends' // TODO: Add Danish translation
},
// Wishlist
wishlist: {
title: 'Wishlist', // TODO: Add Danish translation
addItem: 'Add Item', // TODO: Add Danish translation
editItem: 'Edit Item', // TODO: Add Danish translation
deleteItem: 'Delete Item', // TODO: Add Danish translation
reserve: 'Reserve', // TODO: Add Danish translation
unreserve: 'Unreserve', // TODO: Add Danish translation
reserved: 'Reserved', // TODO: Add Danish translation
save: 'Save', // TODO: Add Danish translation
saveWishlist: 'Save Wishlist', // TODO: Add Danish translation
share: 'Share', // TODO: Add Danish translation
edit: 'Edit', // TODO: Add Danish translation
back: 'Back', // TODO: Add Danish translation
noItems: 'No items yet', // TODO: Add Danish translation
addFirstItem: 'Add your first item' // TODO: Add Danish translation
},
// Forms
form: {
title: 'Title', // TODO: Add Danish translation
description: 'Description', // TODO: Add Danish translation
price: 'Price', // TODO: Add Danish translation
url: 'URL', // TODO: Add Danish translation
image: 'Image', // TODO: Add Danish translation
submit: 'Submit', // TODO: Add Danish translation
cancel: 'Cancel', // TODO: Add Danish translation
save: 'Save', // TODO: Add Danish translation
delete: 'Delete', // TODO: Add Danish translation
email: 'Email', // TODO: Add Danish translation
password: 'Password', // TODO: Add Danish translation
name: 'Name', // TODO: Add Danish translation
username: 'Username' // TODO: Add Danish translation
},
// Auth
auth: {
signIn: 'Sign In', // TODO: Add Danish translation
signUp: 'Sign Up', // TODO: Add Danish translation
signOut: 'Sign Out', // TODO: Add Danish translation
welcome: 'Welcome', // TODO: Add Danish translation
createAccount: 'Create Account', // TODO: Add Danish translation
alreadyHaveAccount: 'Already have an account?', // TODO: Add Danish translation
dontHaveAccount: "Don't have an account?" // TODO: Add Danish translation
},
// Common
common: {
loading: 'Loading...', // TODO: Add Danish translation
error: 'Error', // TODO: Add Danish translation
success: 'Success', // TODO: Add Danish translation
confirm: 'Confirm', // TODO: Add Danish translation
close: 'Close', // TODO: Add Danish translation
or: 'or', // TODO: Add Danish translation
and: 'and' // TODO: Add Danish translation
},
// Date formatting
date: {
format: {
short: 'da-DK',
long: 'da-DK'
}
}
};

View File

@@ -0,0 +1,168 @@
export const en = {
// Navigation
nav: {
dashboard: 'Dashboard'
},
// Dashboard
dashboard: {
myWishlists: 'My Wishlists',
myWishlistsDescription: 'Wishlists you own and manage',
savedWishlists: 'Saved Wishlists',
savedWishlistsDescription: "Wishlists you're following",
createNew: '+ Create New',
manage: 'Manage',
copyLink: 'Copy Link',
viewWishlist: 'View Wishlist',
unsave: 'Unsave',
emptyWishlists: "You haven't created any wishlists yet.",
emptyWishlistsAction: 'Create Your First Wishlist',
emptySavedWishlists: "You haven't saved any wishlists yet.",
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.",
by: 'by',
ends: 'Ends'
},
// Wishlist
wishlist: {
title: 'Wishlist',
addItem: 'Add Item',
editItem: 'Edit Item',
deleteItem: 'Delete Item',
reserve: 'Reserve',
unreserve: 'Unreserve',
reserved: 'Reserved',
save: 'Save',
saveWishlist: 'Save Wishlist',
share: 'Share',
edit: 'Edit',
back: 'Back',
noItems: 'No items yet',
addFirstItem: 'Add your first item'
},
// Forms
form: {
title: 'Title',
description: 'Description',
price: 'Price',
url: 'URL',
image: 'Image',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save',
delete: 'Delete',
email: 'Email',
password: 'Password',
name: 'Name',
username: 'Username'
},
// Auth
auth: {
signIn: 'Sign In',
signUp: 'Sign Up',
signOut: 'Sign Out',
welcome: 'Welcome',
createAccount: 'Create Account',
alreadyHaveAccount: 'Already have an account?',
dontHaveAccount: "Don't have an account?"
},
// Common
common: {
loading: 'Loading...',
error: 'Error',
success: 'Success',
confirm: 'Confirm',
close: 'Close',
or: 'or',
and: 'and'
},
// Date formatting
date: {
format: {
short: 'en-US',
long: 'en-US'
}
}
};
export type Translation = {
nav: {
dashboard: string;
};
dashboard: {
myWishlists: string;
myWishlistsDescription: string;
savedWishlists: string;
savedWishlistsDescription: string;
createNew: string;
manage: string;
copyLink: string;
viewWishlist: string;
unsave: string;
emptyWishlists: string;
emptyWishlistsAction: string;
emptySavedWishlists: string;
emptySavedWishlistsDescription: string;
by: string;
ends: string;
};
wishlist: {
title: string;
addItem: string;
editItem: string;
deleteItem: string;
reserve: string;
unreserve: string;
reserved: string;
save: string;
saveWishlist: string;
share: string;
edit: string;
back: string;
noItems: string;
addFirstItem: string;
};
form: {
title: string;
description: string;
price: string;
url: string;
image: string;
submit: string;
cancel: string;
save: string;
delete: string;
email: string;
password: string;
name: string;
username: string;
};
auth: {
signIn: string;
signUp: string;
signOut: string;
welcome: string;
createAccount: string;
alreadyHaveAccount: string;
dontHaveAccount: string;
};
common: {
loading: string;
error: string;
success: string;
confirm: string;
close: string;
or: string;
and: string;
};
date: {
format: {
short: string;
long: string;
};
};
};

View File

@@ -0,0 +1,15 @@
import { en } from './en';
import { da } from './da';
import type { Translation } from './en';
export const translations: Record<string, Translation> = {
en,
da
};
export const languages = [
{ code: 'en', name: 'English' },
{ code: 'da', name: 'Dansk' }
] as const;
export type LanguageCode = 'en' | 'da';

1
src/lib/index.ts Normal file
View File

@@ -0,0 +1 @@

7
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { env } from '$env/dynamic/private';
import * as schema from './schema';
const client = postgres(env.DATABASE_URL!);
export const db = drizzle(client, { schema });

166
src/lib/server/schema.ts Normal file
View File

@@ -0,0 +1,166 @@
import { pgTable, text, timestamp, numeric, boolean, primaryKey } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2';
import type { AdapterAccountType } from '@auth/core/adapters';
export const users = pgTable('user', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password'),
username: text('username').unique()
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
);
export const wishlists = pgTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
}));
export const items = pgTable('items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const itemsRelations = relations(items, ({ one, many }) => ({
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
}));
export const reservations = pgTable('reservations', {
id: text('id').primaryKey().$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const reservationsRelations = relations(reservations, ({ one }) => ({
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
})
}));
export const savedWishlists = pgTable('saved_wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
}));
export const usersRelations = relations(users, ({ many }) => ({
wishlists: many(wishlists),
savedWishlists: many(savedWishlists)
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Wishlist = typeof wishlists.$inferSelect;
export type NewWishlist = typeof wishlists.$inferInsert;
export type Item = typeof items.$inferSelect;
export type NewItem = typeof items.$inferInsert;
export type Reservation = typeof reservations.$inferSelect;
export type NewReservation = typeof reservations.$inferInsert;
export type SavedWishlist = typeof savedWishlists.$inferSelect;
export type NewSavedWishlist = typeof savedWishlists.$inferInsert;

View File

@@ -0,0 +1,63 @@
import { translations, type LanguageCode } from '$lib/i18n/translations';
import type { Translation } from '$lib/i18n/translations/en';
const LANGUAGE_KEY = 'preferred-language';
function getStoredLanguage(): LanguageCode {
if (typeof window === 'undefined') return 'en';
const stored = localStorage.getItem(LANGUAGE_KEY);
if (stored && (stored === 'en' || stored === 'da')) {
return stored as LanguageCode;
}
// Try to detect from browser
const browserLang = navigator.language.toLowerCase();
if (browserLang.startsWith('da')) {
return 'da';
}
return 'en';
}
class LanguageStore {
private _current = $state<LanguageCode>(getStoredLanguage());
get current(): LanguageCode {
return this._current;
}
set current(value: LanguageCode) {
this._current = value;
if (typeof window !== 'undefined') {
localStorage.setItem(LANGUAGE_KEY, value);
}
}
get t(): Translation {
return translations[this._current];
}
setLanguage(lang: LanguageCode) {
this.current = lang;
}
}
export const languageStore = new LanguageStore();
// Helper function to get nested translation value
export function t(path: string): string {
const keys = path.split('.');
let value: any = languageStore.t;
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key];
} else {
console.warn(`Translation key not found: ${path}`);
return path;
}
}
return typeof value === 'string' ? value : path;
}

View File

@@ -0,0 +1,63 @@
import { browser } from '$app/environment';
type Theme = 'light' | 'dark' | 'system';
class ThemeStore {
current = $state<Theme>('system');
constructor() {
if (browser) {
const stored = localStorage.getItem('theme') as Theme | null;
this.current = stored || 'system';
this.applyTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
// Re-apply theme if in system mode
if (this.current === 'system') {
this.applyTheme();
}
});
}
}
private applyTheme() {
if (!browser) return;
const isDark = this.current === 'dark' ||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
toggle() {
// Cycle through: light -> dark -> system -> light
if (this.current === 'light') {
this.current = 'dark';
} else if (this.current === 'dark') {
this.current = 'system';
} else {
this.current = 'light';
}
if (browser) {
localStorage.setItem('theme', this.current);
this.applyTheme();
}
}
set(theme: Theme) {
this.current = theme;
if (browser) {
localStorage.setItem('theme', this.current);
}
this.applyTheme();
}
}
export const themeStore = new ThemeStore();

13
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,13 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };

18
src/lib/utils/colors.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Convert hex color to rgba with transparency
*/
export function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/**
* Generate card style string with color, transparency, and blur
*/
export function getCardStyle(color: string | null): string {
if (!color) return '';
return `background-color: ${hexToRgba(color, 0.2)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
}