initial production version
This commit is contained in:
45
src/lib/components/dashboard/WishlistCard.svelte
Normal file
45
src/lib/components/dashboard/WishlistCard.svelte
Normal 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>
|
||||
79
src/lib/components/dashboard/WishlistGrid.svelte
Normal file
79
src/lib/components/dashboard/WishlistGrid.svelte
Normal 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>
|
||||
18
src/lib/components/layout/DashboardHeader.svelte
Normal file
18
src/lib/components/layout/DashboardHeader.svelte
Normal 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>
|
||||
45
src/lib/components/layout/EmptyState.svelte
Normal file
45
src/lib/components/layout/EmptyState.svelte
Normal 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>
|
||||
34
src/lib/components/layout/Navigation.svelte
Normal file
34
src/lib/components/layout/Navigation.svelte
Normal 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>
|
||||
11
src/lib/components/layout/PageContainer.svelte
Normal file
11
src/lib/components/layout/PageContainer.svelte
Normal 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>
|
||||
63
src/lib/components/ui/ColorPicker.svelte
Normal file
63
src/lib/components/ui/ColorPicker.svelte
Normal 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>
|
||||
83
src/lib/components/ui/button/button.svelte
Normal file
83
src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
16
src/lib/components/ui/button/index.ts
Normal file
16
src/lib/components/ui/button/index.ts
Normal 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
|
||||
};
|
||||
14
src/lib/components/ui/card/card-content.svelte
Normal file
14
src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
14
src/lib/components/ui/card/card-description.svelte
Normal file
14
src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
14
src/lib/components/ui/card/card-header.svelte
Normal file
14
src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
14
src/lib/components/ui/card/card-title.svelte
Normal file
14
src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
17
src/lib/components/ui/card/card.svelte
Normal file
17
src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
19
src/lib/components/ui/card/index.ts
Normal file
19
src/lib/components/ui/card/index.ts
Normal 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
|
||||
};
|
||||
3
src/lib/components/ui/input/index.ts
Normal file
3
src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Root from './input.svelte';
|
||||
|
||||
export { Root, Root as Input };
|
||||
20
src/lib/components/ui/input/input.svelte
Normal file
20
src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
/>
|
||||
3
src/lib/components/ui/label/index.ts
Normal file
3
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Root from './label.svelte';
|
||||
|
||||
export { Root, Root as Label };
|
||||
20
src/lib/components/ui/label/label.svelte
Normal file
20
src/lib/components/ui/label/label.svelte
Normal 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>
|
||||
58
src/lib/components/ui/language-toggle/LanguageToggle.svelte
Normal file
58
src/lib/components/ui/language-toggle/LanguageToggle.svelte
Normal 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>
|
||||
2
src/lib/components/ui/language-toggle/index.ts
Normal file
2
src/lib/components/ui/language-toggle/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import LanguageToggle from './LanguageToggle.svelte';
|
||||
export { LanguageToggle };
|
||||
3
src/lib/components/ui/textarea/index.ts
Normal file
3
src/lib/components/ui/textarea/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import Root from './textarea.svelte';
|
||||
|
||||
export { Root, Root as Textarea };
|
||||
19
src/lib/components/ui/textarea/textarea.svelte
Normal file
19
src/lib/components/ui/textarea/textarea.svelte
Normal 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>
|
||||
22
src/lib/components/ui/theme-toggle/ThemeToggle.svelte
Normal file
22
src/lib/components/ui/theme-toggle/ThemeToggle.svelte
Normal 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>
|
||||
1
src/lib/components/ui/theme-toggle/index.ts
Normal file
1
src/lib/components/ui/theme-toggle/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
||||
140
src/lib/components/wishlist/AddItemForm.svelte
Normal file
140
src/lib/components/wishlist/AddItemForm.svelte
Normal 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>
|
||||
176
src/lib/components/wishlist/EditItemForm.svelte
Normal file
176
src/lib/components/wishlist/EditItemForm.svelte
Normal 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>
|
||||
72
src/lib/components/wishlist/EditableItemsList.svelte
Normal file
72
src/lib/components/wishlist/EditableItemsList.svelte
Normal 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>
|
||||
33
src/lib/components/wishlist/ImageSelector.svelte
Normal file
33
src/lib/components/wishlist/ImageSelector.svelte
Normal 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}
|
||||
70
src/lib/components/wishlist/ReservationButton.svelte
Normal file
70
src/lib/components/wishlist/ReservationButton.svelte
Normal 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}
|
||||
58
src/lib/components/wishlist/ShareLinks.svelte
Normal file
58
src/lib/components/wishlist/ShareLinks.svelte
Normal 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>
|
||||
33
src/lib/components/wishlist/WishlistActionButtons.svelte
Normal file
33
src/lib/components/wishlist/WishlistActionButtons.svelte
Normal 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>
|
||||
191
src/lib/components/wishlist/WishlistHeader.svelte
Normal file
191
src/lib/components/wishlist/WishlistHeader.svelte
Normal 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>
|
||||
116
src/lib/components/wishlist/WishlistItem.svelte
Normal file
116
src/lib/components/wishlist/WishlistItem.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user