initial production version
This commit is contained in:
129
src/app.css
Normal file
129
src/app.css
Normal file
@@ -0,0 +1,129 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
* {
|
||||
transition:
|
||||
background-color 1s ease,
|
||||
background-image 1s ease,
|
||||
color 1s ease,
|
||||
border-color 1s ease;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
14
src/app.d.ts
vendored
Normal file
14
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Session } from '@auth/core/types';
|
||||
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
session: Session | null;
|
||||
}
|
||||
interface PageData {
|
||||
session: Session | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
21
src/app.html
Normal file
21
src/app.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
(function() {
|
||||
const theme = localStorage.getItem('theme') || 'system';
|
||||
const isDark = theme === 'dark' ||
|
||||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (isDark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
123
src/auth.ts
Normal file
123
src/auth.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||
import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||
import Credentials from '@auth/core/providers/credentials';
|
||||
import Google from '@auth/core/providers/google';
|
||||
import type { OAuthConfig } from '@auth/core/providers';
|
||||
import { db } from '$lib/server/db';
|
||||
import { users } from '$lib/server/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { SvelteKitAuthConfig } from '@auth/sveltekit';
|
||||
|
||||
function Authentik(config: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string;
|
||||
}): OAuthConfig<any> {
|
||||
return {
|
||||
id: 'authentik',
|
||||
name: 'Authentik',
|
||||
type: 'oidc',
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
issuer: config.issuer,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: 'openid email profile'
|
||||
}
|
||||
},
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.sub,
|
||||
email: profile.email,
|
||||
name: profile.name || profile.preferred_username,
|
||||
image: profile.picture
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const authConfig: SvelteKitAuthConfig = {
|
||||
adapter: DrizzleAdapter(db),
|
||||
session: {
|
||||
strategy: 'jwt'
|
||||
},
|
||||
providers: [
|
||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||
? [
|
||||
Google({
|
||||
clientId: env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET
|
||||
})
|
||||
]
|
||||
: []),
|
||||
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
|
||||
? [
|
||||
Authentik({
|
||||
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
|
||||
issuer: env.AUTHENTIK_ISSUER
|
||||
})
|
||||
]
|
||||
: []),
|
||||
Credentials({
|
||||
id: 'credentials',
|
||||
name: 'credentials',
|
||||
credentials: {
|
||||
username: { label: 'Username', type: 'text' },
|
||||
password: { label: 'Password', type: 'password' }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.username || !credentials?.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, credentials.username as string)
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(
|
||||
credentials.password as string,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email || undefined,
|
||||
name: user.name,
|
||||
image: user.image
|
||||
};
|
||||
}
|
||||
})
|
||||
],
|
||||
pages: {
|
||||
signIn: '/signin'
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token && session.user) {
|
||||
session.user.id = token.id as string;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
},
|
||||
secret: env.AUTH_SECRET,
|
||||
trustHost: true
|
||||
};
|
||||
|
||||
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);
|
||||
1
src/hooks.server.ts
Normal file
1
src/hooks.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { handle } from './auth';
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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 |
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>
|
||||
93
src/lib/i18n/translations/da.ts
Normal file
93
src/lib/i18n/translations/da.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
};
|
||||
168
src/lib/i18n/translations/en.ts
Normal file
168
src/lib/i18n/translations/en.ts
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
15
src/lib/i18n/translations/index.ts
Normal file
15
src/lib/i18n/translations/index.ts
Normal 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
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
src/lib/server/db.ts
Normal file
7
src/lib/server/db.ts
Normal 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
166
src/lib/server/schema.ts
Normal 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;
|
||||
63
src/lib/stores/language.svelte.ts
Normal file
63
src/lib/stores/language.svelte.ts
Normal 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;
|
||||
}
|
||||
63
src/lib/stores/theme.svelte.ts
Normal file
63
src/lib/stores/theme.svelte.ts
Normal 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
13
src/lib/utils.ts
Normal 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
18
src/lib/utils/colors.ts
Normal 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;`;
|
||||
}
|
||||
14
src/routes/+layout.svelte
Normal file
14
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||
{@render children()}
|
||||
</div>
|
||||
8
src/routes/+page.server.ts
Normal file
8
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const session = await event.locals.auth();
|
||||
return {
|
||||
session
|
||||
};
|
||||
};
|
||||
94
src/routes/+page.svelte
Normal file
94
src/routes/+page.svelte
Normal file
@@ -0,0 +1,94 @@
|
||||
<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, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||
import { goto } from '$app/navigation';
|
||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let color = $state<string | null>(null);
|
||||
let isCreating = $state(false);
|
||||
|
||||
async function createWishlist() {
|
||||
if (!title.trim()) return;
|
||||
|
||||
isCreating = true;
|
||||
try {
|
||||
const response = await fetch('/api/wishlists', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, description, color })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { ownerToken } = await response.json();
|
||||
goto(`/wishlist/${ownerToken}/edit`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create wishlist:', error);
|
||||
} finally {
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<Card class="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle class="text-3xl">Create Your Wishlist</CardTitle>
|
||||
<CardDescription>
|
||||
Create a wishlist and share it with friends and family
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
{#if data.session?.user}
|
||||
<Button variant="outline" onclick={() => goto('/dashboard')}>Dashboard</Button>
|
||||
{:else}
|
||||
<Button variant="outline" onclick={() => goto('/signin')}>Sign In</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="title">Wishlist Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
bind:value={title}
|
||||
placeholder="My Birthday Wishlist"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="description">Description (optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={description}
|
||||
placeholder="Add some context for your wishlist..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="color">Wishlist Color (optional)</Label>
|
||||
<ColorPicker bind:color={color} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
|
||||
{isCreating ? 'Creating...' : 'Create Wishlist'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
75
src/routes/api/scrape-images/+server.ts
Normal file
75
src/routes/api/scrape-images/+server.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
const { url } = await request.json();
|
||||
|
||||
if (!url) {
|
||||
return json({ error: 'URL is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return json({ error: 'Failed to fetch URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const baseUrl = new URL(url);
|
||||
const origin = baseUrl.origin;
|
||||
|
||||
const imageUrls: string[] = [];
|
||||
const imgRegex = /<img[^>]+src="([^">]+)"/g;
|
||||
const ogImageRegex = /<meta[^>]+property="og:image"[^>]+content="([^">]+)"/g;
|
||||
const twitterImageRegex = /<meta[^>]+name="twitter:image"[^>]+content="([^">]+)"/g;
|
||||
|
||||
function toAbsoluteUrl(imgUrl: string): string {
|
||||
if (imgUrl.startsWith('http')) {
|
||||
return imgUrl;
|
||||
}
|
||||
if (imgUrl.startsWith('//')) {
|
||||
return `https:${imgUrl}`;
|
||||
}
|
||||
if (imgUrl.startsWith('/')) {
|
||||
return `${origin}${imgUrl}`;
|
||||
}
|
||||
return `${origin}/${imgUrl}`;
|
||||
}
|
||||
|
||||
let match;
|
||||
|
||||
while ((match = ogImageRegex.exec(html)) !== null) {
|
||||
imageUrls.push(toAbsoluteUrl(match[1]));
|
||||
}
|
||||
|
||||
while ((match = twitterImageRegex.exec(html)) !== null) {
|
||||
imageUrls.push(toAbsoluteUrl(match[1]));
|
||||
}
|
||||
|
||||
while ((match = imgRegex.exec(html)) !== null) {
|
||||
const imgUrl = match[1];
|
||||
const fullUrl = toAbsoluteUrl(imgUrl);
|
||||
if (!imageUrls.includes(fullUrl)) {
|
||||
imageUrls.push(fullUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredImages = imageUrls.filter(
|
||||
(url) =>
|
||||
!url.includes('logo') &&
|
||||
!url.includes('icon') &&
|
||||
!url.includes('sprite') &&
|
||||
!url.endsWith('.svg') &&
|
||||
url.length < 500
|
||||
);
|
||||
|
||||
return json({ images: filteredImages.slice(0, 20) });
|
||||
} catch (error) {
|
||||
return json({ error: 'Failed to scrape images' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
33
src/routes/api/wishlists/+server.ts
Normal file
33
src/routes/api/wishlists/+server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { wishlists } from '$lib/server/schema';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const { title, description, color } = await request.json();
|
||||
|
||||
if (!title?.trim()) {
|
||||
return json({ error: 'Title is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await locals.auth();
|
||||
const userId = session?.user?.id || null;
|
||||
|
||||
const ownerToken = createId();
|
||||
const publicToken = createId();
|
||||
|
||||
const [wishlist] = await db
|
||||
.insert(wishlists)
|
||||
.values({
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
color: color?.trim() || null,
|
||||
ownerToken,
|
||||
publicToken,
|
||||
userId
|
||||
})
|
||||
.returning();
|
||||
|
||||
return json({ ownerToken, publicToken, id: wishlist.id });
|
||||
};
|
||||
111
src/routes/dashboard/+page.server.ts
Normal file
111
src/routes/dashboard/+page.server.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { wishlists, savedWishlists } from '$lib/server/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const session = await event.locals.auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw redirect(303, '/signin');
|
||||
}
|
||||
|
||||
const userWishlists = await db.query.wishlists.findMany({
|
||||
where: eq(wishlists.userId, session.user.id),
|
||||
with: {
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.order)]
|
||||
},
|
||||
user: true
|
||||
},
|
||||
orderBy: (wishlists, { desc }) => [desc(wishlists.createdAt)]
|
||||
});
|
||||
|
||||
const saved = await db.query.savedWishlists.findMany({
|
||||
where: eq(savedWishlists.userId, session.user.id),
|
||||
with: {
|
||||
wishlist: {
|
||||
with: {
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.order)]
|
||||
},
|
||||
user: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: (savedWishlists, { desc }) => [desc(savedWishlists.createdAt)]
|
||||
});
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
wishlists: userWishlists,
|
||||
savedWishlists: saved
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
toggleFavorite: async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.id) {
|
||||
throw redirect(303, '/signin');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const wishlistId = formData.get('wishlistId') as string;
|
||||
const isFavorite = formData.get('isFavorite') === 'true';
|
||||
|
||||
if (!wishlistId) {
|
||||
return { success: false, error: 'Wishlist ID is required' };
|
||||
}
|
||||
|
||||
await db.update(wishlists)
|
||||
.set({ isFavorite: !isFavorite })
|
||||
.where(eq(wishlists.id, wishlistId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
toggleSavedFavorite: async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.id) {
|
||||
throw redirect(303, '/signin');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||
const isFavorite = formData.get('isFavorite') === 'true';
|
||||
|
||||
if (!savedWishlistId) {
|
||||
return { success: false, error: 'Saved wishlist ID is required' };
|
||||
}
|
||||
|
||||
await db.update(savedWishlists)
|
||||
.set({ isFavorite: !isFavorite })
|
||||
.where(eq(savedWishlists.id, savedWishlistId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
unsaveWishlist: async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
if (!session?.user?.id) {
|
||||
throw redirect(303, '/signin');
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||
|
||||
if (!savedWishlistId) {
|
||||
return { success: false, error: 'Saved wishlist ID is required' };
|
||||
}
|
||||
|
||||
await db.delete(savedWishlists)
|
||||
.where(and(
|
||||
eq(savedWishlists.id, savedWishlistId),
|
||||
eq(savedWishlists.userId, session.user.id)
|
||||
));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
189
src/routes/dashboard/+page.svelte
Normal file
189
src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import type { PageData } from './$types';
|
||||
import PageContainer from '$lib/components/layout/PageContainer.svelte';
|
||||
import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte';
|
||||
import WishlistGrid from '$lib/components/dashboard/WishlistGrid.svelte';
|
||||
import WishlistCard from '$lib/components/dashboard/WishlistCard.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { Star } from 'lucide-svelte';
|
||||
import { languageStore } from '$lib/stores/language.svelte';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const t = $derived(languageStore.t);
|
||||
|
||||
const sortedWishlists = $derived(
|
||||
[...data.wishlists].sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
|
||||
const aHasEndDate = !!a.endDate;
|
||||
const bHasEndDate = !!b.endDate;
|
||||
|
||||
if (aHasEndDate && !bHasEndDate) return -1;
|
||||
if (!aHasEndDate && bHasEndDate) return 1;
|
||||
|
||||
if (aHasEndDate && bHasEndDate) {
|
||||
return new Date(a.endDate!).getTime() - new Date(b.endDate!).getTime();
|
||||
}
|
||||
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
);
|
||||
|
||||
const sortedSavedWishlists = $derived(
|
||||
[...data.savedWishlists].sort((a, b) => {
|
||||
if (a.isFavorite && !b.isFavorite) return -1;
|
||||
if (!a.isFavorite && b.isFavorite) return 1;
|
||||
|
||||
const aHasEndDate = !!a.wishlist?.endDate;
|
||||
const bHasEndDate = !!b.wishlist?.endDate;
|
||||
|
||||
if (aHasEndDate && !bHasEndDate) return -1;
|
||||
if (!aHasEndDate && bHasEndDate) return 1;
|
||||
|
||||
if (aHasEndDate && bHasEndDate) {
|
||||
return new Date(a.wishlist.endDate!).getTime() - new Date(b.wishlist.endDate!).getTime();
|
||||
}
|
||||
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
})
|
||||
);
|
||||
|
||||
function formatEndDate(date: Date | string | null): string | null {
|
||||
if (!date) return null;
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString(languageStore.t.date.format.short, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function getWishlistDescription(wishlist: any): string | null {
|
||||
if (!wishlist) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
const topItems = wishlist.items?.slice(0, 3).map((item: any) => item.title) || [];
|
||||
if (topItems.length > 0) {
|
||||
lines.push(topItems.join(', '));
|
||||
}
|
||||
|
||||
if (wishlist.user?.name || wishlist.user?.username) {
|
||||
const ownerName = wishlist.user.name || wishlist.user.username;
|
||||
lines.push(`${t.dashboard.by} ${ownerName}`);
|
||||
}
|
||||
|
||||
if (wishlist.endDate) {
|
||||
lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`);
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
function getSavedWishlistDescription(saved: any): string | null {
|
||||
return getWishlistDescription(saved.wishlist);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageContainer>
|
||||
<DashboardHeader userName={data.user?.name} userEmail={data.user?.email} />
|
||||
|
||||
<WishlistGrid
|
||||
title={t.dashboard.myWishlists}
|
||||
description={t.dashboard.myWishlistsDescription}
|
||||
items={sortedWishlists || []}
|
||||
emptyMessage={t.dashboard.emptyWishlists}
|
||||
emptyActionLabel={t.dashboard.emptyWishlistsAction}
|
||||
emptyActionHref="/"
|
||||
>
|
||||
{#snippet headerAction()}
|
||||
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet children(wishlist)}
|
||||
<WishlistCard
|
||||
title={wishlist.title}
|
||||
description={getWishlistDescription(wishlist)}
|
||||
itemCount={wishlist.items?.length || 0}
|
||||
color={wishlist.color}
|
||||
>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<form method="POST" action="?/toggleFavorite" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="wishlistId" value={wishlist.id} />
|
||||
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
||||
>
|
||||
{t.dashboard.manage}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t.dashboard.copyLink}
|
||||
</Button>
|
||||
</div>
|
||||
</WishlistCard>
|
||||
{/snippet}
|
||||
</WishlistGrid>
|
||||
|
||||
<WishlistGrid
|
||||
title={t.dashboard.savedWishlists}
|
||||
description={t.dashboard.savedWishlistsDescription}
|
||||
items={sortedSavedWishlists || []}
|
||||
emptyMessage={t.dashboard.emptySavedWishlists}
|
||||
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
|
||||
>
|
||||
{#snippet children(saved)}
|
||||
<WishlistCard
|
||||
title={saved.wishlist?.title}
|
||||
description={getSavedWishlistDescription(saved)}
|
||||
itemCount={saved.wishlist?.items?.length || 0}
|
||||
color={saved.wishlist?.color}
|
||||
>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
||||
<input type="hidden" name="isFavorite" value={saved.isFavorite} />
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
<Star class={saved.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
size="sm"
|
||||
onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)}
|
||||
>
|
||||
{t.dashboard.viewWishlist}
|
||||
</Button>
|
||||
<form method="POST" action="?/unsaveWishlist" use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update({ reset: false });
|
||||
};
|
||||
}}>
|
||||
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
||||
<Button type="submit" size="sm" variant="destructive">
|
||||
{t.dashboard.unsave}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</WishlistCard>
|
||||
{/snippet}
|
||||
</WishlistGrid>
|
||||
</PageContainer>
|
||||
24
src/routes/signin/+page.server.ts
Normal file
24
src/routes/signin/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const registered = url.searchParams.get('registered');
|
||||
const error = url.searchParams.get('error');
|
||||
|
||||
// Determine which OAuth providers are available
|
||||
const oauthProviders = [];
|
||||
|
||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
oauthProviders.push({ id: 'google', name: 'Google' });
|
||||
}
|
||||
|
||||
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
||||
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
||||
}
|
||||
|
||||
return {
|
||||
registered: registered === 'true',
|
||||
error: error,
|
||||
oauthProviders
|
||||
};
|
||||
};
|
||||
102
src/routes/signin/+page.svelte
Normal file
102
src/routes/signin/+page.svelte
Normal file
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||
import type { PageData } from './$types';
|
||||
import { signIn } from '@auth/sveltekit/client';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
isSubmitting = true;
|
||||
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string;
|
||||
|
||||
try {
|
||||
await signIn('credentials', {
|
||||
username,
|
||||
password,
|
||||
callbackUrl: '/dashboard'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sign in error:', error);
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Card class="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">Welcome Back</CardTitle>
|
||||
<CardDescription>Sign in to your account</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if data.registered}
|
||||
<div class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded">
|
||||
Account created successfully! Please sign in.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.error}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
|
||||
Invalid username or password
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="username">Username</Label>
|
||||
<Input id="username" name="username" type="text" required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" name="password" type="password" required />
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{#if data.oauthProviders.length > 0}
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<span class="w-full border-t"></span>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-card px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each data.oauthProviders as provider}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
||||
>
|
||||
Sign in with {provider.name}
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
Don't have an account?
|
||||
<a href="/signup" class="text-primary hover:underline">Sign up</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
68
src/routes/signup/+page.server.ts
Normal file
68
src/routes/signup/+page.server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { users } from '$lib/server/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Determine which OAuth providers are available
|
||||
const oauthProviders = [];
|
||||
|
||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
oauthProviders.push({ id: 'google', name: 'Google' });
|
||||
}
|
||||
|
||||
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
||||
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
||||
}
|
||||
|
||||
return {
|
||||
oauthProviders
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const username = formData.get('username') as string;
|
||||
const password = formData.get('password') as string;
|
||||
const confirmPassword = formData.get('confirmPassword') as string;
|
||||
|
||||
if (!name?.trim()) {
|
||||
return fail(400, { error: 'Name is required', name, username });
|
||||
}
|
||||
|
||||
if (!username?.trim()) {
|
||||
return fail(400, { error: 'Username is required', name, username });
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return fail(400, { error: 'Password must be at least 8 characters', name, username });
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return fail(400, { error: 'Passwords do not match', name, username });
|
||||
}
|
||||
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.username, username.trim().toLowerCase())
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return fail(400, { error: 'Username already taken', name, username });
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
await db.insert(users).values({
|
||||
name: name.trim(),
|
||||
username: username.trim().toLowerCase(),
|
||||
password: hashedPassword
|
||||
});
|
||||
|
||||
throw redirect(303, '/signin?registered=true');
|
||||
}
|
||||
};
|
||||
81
src/routes/signup/+page.svelte
Normal file
81
src/routes/signup/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { signIn } from '@auth/sveltekit/client';
|
||||
|
||||
let { form, data }: { form: ActionData; data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<Card class="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-2xl">Create an Account</CardTitle>
|
||||
<CardDescription>Sign up to manage your wishlists</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if form?.error}
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">Name</Label>
|
||||
<Input id="name" name="name" type="text" required value={form?.name || ''} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="username">Username</Label>
|
||||
<Input id="username" name="username" type="text" required value={form?.username || ''} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password" name="password" type="password" required minlength={8} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="confirmPassword">Confirm Password</Label>
|
||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minlength={8} />
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full">Sign Up</Button>
|
||||
</form>
|
||||
|
||||
{#if data.oauthProviders.length > 0}
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<span class="w-full border-t"></span>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-card px-2 text-muted-foreground">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each data.oauthProviders as provider}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
||||
>
|
||||
Sign up with {provider.name}
|
||||
</Button>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="text-center text-sm text-muted-foreground">
|
||||
Already have an account?
|
||||
<a href="/signin" class="text-primary hover:underline">Sign in</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
158
src/routes/wishlist/[token]/+page.server.ts
Normal file
158
src/routes/wishlist/[token]/+page.server.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { wishlists, items, reservations, savedWishlists } from '$lib/server/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.publicToken, params.token),
|
||||
with: {
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.order)],
|
||||
with: {
|
||||
reservations: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
const session = await locals.auth();
|
||||
let isSaved = false;
|
||||
let savedWishlistId: string | null = null;
|
||||
|
||||
if (session?.user?.id) {
|
||||
const saved = await db.query.savedWishlists.findFirst({
|
||||
where: and(
|
||||
eq(savedWishlists.userId, session.user.id),
|
||||
eq(savedWishlists.wishlistId, wishlist.id)
|
||||
)
|
||||
});
|
||||
isSaved = !!saved;
|
||||
savedWishlistId = saved?.id || null;
|
||||
}
|
||||
|
||||
return {
|
||||
wishlist,
|
||||
isSaved,
|
||||
savedWishlistId,
|
||||
isAuthenticated: !!session?.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
reserve: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get('itemId') as string;
|
||||
const reserverName = formData.get('reserverName') as string;
|
||||
|
||||
if (!itemId) {
|
||||
return { success: false, error: 'Item ID is required' };
|
||||
}
|
||||
|
||||
const existingReservation = await db.query.reservations.findFirst({
|
||||
where: eq(reservations.itemId, itemId)
|
||||
});
|
||||
|
||||
if (existingReservation) {
|
||||
return { success: false, error: 'This item is already reserved' };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(reservations).values({
|
||||
itemId,
|
||||
reserverName: reserverName?.trim() || null
|
||||
});
|
||||
|
||||
await tx
|
||||
.update(items)
|
||||
.set({ isReserved: true })
|
||||
.where(eq(items.id, itemId));
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
unreserve: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get('itemId') as string;
|
||||
|
||||
if (!itemId) {
|
||||
return { success: false, error: 'Item ID is required' };
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
||||
|
||||
await tx
|
||||
.update(items)
|
||||
.set({ isReserved: false })
|
||||
.where(eq(items.id, itemId));
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
saveWishlist: async ({ request, locals, params }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'You must be logged in to save wishlists' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const wishlistId = formData.get('wishlistId') as string;
|
||||
|
||||
if (!wishlistId) {
|
||||
return { success: false, error: 'Wishlist ID is required' };
|
||||
}
|
||||
|
||||
const existing = await db.query.savedWishlists.findFirst({
|
||||
where: and(
|
||||
eq(savedWishlists.userId, session.user.id),
|
||||
eq(savedWishlists.wishlistId, wishlistId)
|
||||
)
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { success: false, error: 'Wishlist already saved' };
|
||||
}
|
||||
|
||||
await db.insert(savedWishlists).values({
|
||||
userId: session.user.id,
|
||||
wishlistId
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
unsaveWishlist: async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return { success: false, error: 'You must be logged in' };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||
|
||||
if (!savedWishlistId) {
|
||||
return { success: false, error: 'Saved wishlist ID is required' };
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(savedWishlists)
|
||||
.where(
|
||||
and(
|
||||
eq(savedWishlists.id, savedWishlistId),
|
||||
eq(savedWishlists.userId, session.user.id)
|
||||
)
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
137
src/routes/wishlist/[token]/+page.svelte
Normal file
137
src/routes/wishlist/[token]/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "$lib/components/ui/card";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
import type { PageData } from "./$types";
|
||||
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
|
||||
import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte";
|
||||
import PageContainer from "$lib/components/layout/PageContainer.svelte";
|
||||
import Navigation from "$lib/components/layout/Navigation.svelte";
|
||||
import EmptyState from "$lib/components/layout/EmptyState.svelte";
|
||||
import { enhance } from "$app/forms";
|
||||
import { getCardStyle } from "$lib/utils/colors";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let showSaveForm = $state(false);
|
||||
|
||||
const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
|
||||
</script>
|
||||
|
||||
<PageContainer maxWidth="4xl">
|
||||
<Navigation
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
showDashboardLink={true}
|
||||
/>
|
||||
|
||||
<!-- Header -->
|
||||
<Card style={headerCardStyle}>
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<CardTitle class="text-3xl">{data.wishlist.title}</CardTitle>
|
||||
{#if data.wishlist.description}
|
||||
<CardDescription class="text-base"
|
||||
>{data.wishlist.description}</CardDescription
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if data.isAuthenticated}
|
||||
{#if data.isSaved}
|
||||
<form method="POST" action="?/unsaveWishlist" use:enhance>
|
||||
<input
|
||||
type="hidden"
|
||||
name="savedWishlistId"
|
||||
value={data.savedWishlistId}
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Unsave
|
||||
</Button>
|
||||
</form>
|
||||
{:else}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (showSaveForm = !showSaveForm)}
|
||||
>
|
||||
{showSaveForm ? "Cancel" : "Save Wishlist"}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (window.location.href = "/signin")}
|
||||
>
|
||||
Sign in to Save
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Save Confirmation -->
|
||||
{#if showSaveForm && !data.isSaved}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Save This Wishlist</CardTitle>
|
||||
<CardDescription
|
||||
>Save this wishlist to easily find it later in your dashboard</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveWishlist"
|
||||
use:enhance={() => {
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
showSaveForm = false;
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="wishlistId"
|
||||
value={data.wishlist.id}
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit">Save Wishlist</Button>
|
||||
<Button type="button" variant="outline" onclick={() => showSaveForm = false}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<!-- Items List -->
|
||||
<div class="space-y-4">
|
||||
{#if data.wishlist.items && data.wishlist.items.length > 0}
|
||||
{#each data.wishlist.items as item}
|
||||
<WishlistItem {item}>
|
||||
<ReservationButton
|
||||
itemId={item.id}
|
||||
isReserved={item.isReserved}
|
||||
reserverName={item.reservations?.[0]?.reserverName}
|
||||
/>
|
||||
</WishlistItem>
|
||||
{/each}
|
||||
{:else}
|
||||
<Card>
|
||||
<CardContent class="p-12">
|
||||
<EmptyState
|
||||
message="This wishlist doesn't have any items yet."
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
</PageContainer>
|
||||
228
src/routes/wishlist/[token]/edit/+page.server.ts
Normal file
228
src/routes/wishlist/[token]/edit/+page.server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { db } from '$lib/server/db';
|
||||
import { wishlists, items } from '$lib/server/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token),
|
||||
with: {
|
||||
items: {
|
||||
orderBy: (items, { asc }) => [asc(items.order)]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
const session = await locals.auth();
|
||||
|
||||
return {
|
||||
wishlist,
|
||||
publicUrl: `/wishlist/${wishlist.publicToken}`,
|
||||
isAuthenticated: !!session?.user
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addItem: async ({ params, request }) => {
|
||||
const formData = await request.formData();
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const link = formData.get('link') as string;
|
||||
const imageUrl = formData.get('imageUrl') as string;
|
||||
const price = formData.get('price') as string;
|
||||
const currency = formData.get('currency') as string;
|
||||
const color = formData.get('color') as string;
|
||||
|
||||
if (!title?.trim()) {
|
||||
return { success: false, error: 'Title is required' };
|
||||
}
|
||||
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token),
|
||||
with: {
|
||||
items: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
// Get the max order value and add 1
|
||||
const maxOrder = wishlist.items.reduce((max, item) => {
|
||||
const order = Number(item.order) || 0;
|
||||
return order > max ? order : max;
|
||||
}, 0);
|
||||
|
||||
await db.insert(items).values({
|
||||
wishlistId: wishlist.id,
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
link: link?.trim() || null,
|
||||
imageUrl: imageUrl?.trim() || null,
|
||||
price: price ? price.trim() : null,
|
||||
currency: currency?.trim() || 'DKK',
|
||||
color: color?.trim() || null,
|
||||
order: String(maxOrder + 1)
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
updateItem: async ({ params, request }) => {
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get('itemId') as string;
|
||||
const title = formData.get('title') as string;
|
||||
const description = formData.get('description') as string;
|
||||
const link = formData.get('link') as string;
|
||||
const imageUrl = formData.get('imageUrl') as string;
|
||||
const price = formData.get('price') as string;
|
||||
const currency = formData.get('currency') as string;
|
||||
const color = formData.get('color') as string;
|
||||
|
||||
if (!itemId) {
|
||||
return { success: false, error: 'Item ID is required' };
|
||||
}
|
||||
|
||||
if (!title?.trim()) {
|
||||
return { success: false, error: 'Title is required' };
|
||||
}
|
||||
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token)
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
await db.update(items)
|
||||
.set({
|
||||
title: title.trim(),
|
||||
description: description?.trim() || null,
|
||||
link: link?.trim() || null,
|
||||
imageUrl: imageUrl?.trim() || null,
|
||||
price: price ? price.trim() : null,
|
||||
currency: currency?.trim() || 'DKK',
|
||||
color: color?.trim() || null,
|
||||
updatedAt: new Date()
|
||||
})
|
||||
.where(eq(items.id, itemId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteItem: async ({ params, request }) => {
|
||||
const formData = await request.formData();
|
||||
const itemId = formData.get('itemId') as string;
|
||||
|
||||
if (!itemId) {
|
||||
return { success: false, error: 'Item ID is required' };
|
||||
}
|
||||
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token)
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
await db.delete(items).where(eq(items.id, itemId));
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
reorderItems: async ({ params, request }) => {
|
||||
const formData = await request.formData();
|
||||
const itemsJson = formData.get('items') as string;
|
||||
|
||||
if (!itemsJson) {
|
||||
return { success: false, error: 'Items data is required' };
|
||||
}
|
||||
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token)
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
|
||||
|
||||
for (const update of updates) {
|
||||
await db.update(items)
|
||||
.set({ order: String(update.order), updatedAt: new Date() })
|
||||
.where(eq(items.id, update.id));
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
|
||||
deleteWishlist: async ({ params }) => {
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token)
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
|
||||
|
||||
return { success: true, redirect: '/dashboard' };
|
||||
},
|
||||
|
||||
updateWishlist: async ({ params, request }) => {
|
||||
const formData = await request.formData();
|
||||
const color = formData.get('color');
|
||||
const title = formData.get('title');
|
||||
const description = formData.get('description');
|
||||
const endDate = formData.get('endDate');
|
||||
|
||||
const wishlist = await db.query.wishlists.findFirst({
|
||||
where: eq(wishlists.ownerToken, params.token)
|
||||
});
|
||||
|
||||
if (!wishlist) {
|
||||
throw error(404, 'Wishlist not found');
|
||||
}
|
||||
|
||||
const updates: any = {
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
if (color !== null) {
|
||||
updates.color = color?.toString().trim() || null;
|
||||
}
|
||||
|
||||
if (title !== null) {
|
||||
const titleStr = title?.toString().trim();
|
||||
if (!titleStr) {
|
||||
return { success: false, error: 'Title is required' };
|
||||
}
|
||||
updates.title = titleStr;
|
||||
}
|
||||
|
||||
if (description !== null) {
|
||||
updates.description = description?.toString().trim() || null;
|
||||
}
|
||||
|
||||
if (endDate !== null) {
|
||||
const endDateStr = endDate?.toString().trim();
|
||||
updates.endDate = endDateStr ? new Date(endDateStr) : null;
|
||||
}
|
||||
|
||||
await db.update(wishlists)
|
||||
.set(updates)
|
||||
.where(eq(wishlists.id, wishlist.id));
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
300
src/routes/wishlist/[token]/edit/+page.svelte
Normal file
300
src/routes/wishlist/[token]/edit/+page.svelte
Normal file
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
import AddItemForm from "$lib/components/wishlist/AddItemForm.svelte";
|
||||
import EditItemForm from "$lib/components/wishlist/EditItemForm.svelte";
|
||||
import ShareLinks from "$lib/components/wishlist/ShareLinks.svelte";
|
||||
import PageContainer from "$lib/components/layout/PageContainer.svelte";
|
||||
import Navigation from "$lib/components/layout/Navigation.svelte";
|
||||
import WishlistHeader from "$lib/components/wishlist/WishlistHeader.svelte";
|
||||
import WishlistActionButtons from "$lib/components/wishlist/WishlistActionButtons.svelte";
|
||||
import EditableItemsList from "$lib/components/wishlist/EditableItemsList.svelte";
|
||||
import type { Item } from "$lib/server/schema";
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { Search, Lock, LockOpen } from "lucide-svelte";
|
||||
import { enhance } from "$app/forms";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let showAddForm = $state(false);
|
||||
let rearranging = $state(false);
|
||||
let editingItem = $state<Item | null>(null);
|
||||
let addFormElement = $state<HTMLElement | null>(null);
|
||||
let editFormElement = $state<HTMLElement | null>(null);
|
||||
let searchQuery = $state("");
|
||||
|
||||
let sortedItems = $state<Item[]>([]);
|
||||
let filteredItems = $derived(
|
||||
searchQuery.trim()
|
||||
? sortedItems.filter(item =>
|
||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sortedItems
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
sortedItems = [...data.wishlist.items].sort(
|
||||
(a, b) => Number(a.order) - Number(b.order),
|
||||
);
|
||||
});
|
||||
|
||||
function handleItemAdded() {
|
||||
showAddForm = false;
|
||||
}
|
||||
|
||||
function handleItemUpdated() {
|
||||
editingItem = null;
|
||||
}
|
||||
|
||||
function startEditing(item: Item) {
|
||||
editingItem = item;
|
||||
showAddForm = false;
|
||||
setTimeout(() => {
|
||||
editFormElement?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function handleColorChange(itemId: string, newColor: string) {
|
||||
sortedItems = sortedItems.map((item) =>
|
||||
item.id === itemId ? { ...item, color: newColor } : item,
|
||||
);
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
editingItem = null;
|
||||
}
|
||||
|
||||
async function handleReorder(items: Item[]) {
|
||||
const updates = items.map((item, index) => ({
|
||||
id: item.id,
|
||||
order: index,
|
||||
}));
|
||||
|
||||
const response = await fetch("?/reorderItems", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
items: JSON.stringify(updates),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to update item order");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTitleUpdate(title: string): Promise<boolean> {
|
||||
const response = await fetch("?/updateWishlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
title: title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to update wishlist title");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleDescriptionUpdate(description: string | null): Promise<boolean> {
|
||||
const response = await fetch("?/updateWishlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
description: description || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to update wishlist description");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleColorUpdate(color: string | null) {
|
||||
const response = await fetch("?/updateWishlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
color: color || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to update wishlist color");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEndDateUpdate(endDate: string | null) {
|
||||
const response = await fetch("?/updateWishlist", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
endDate: endDate || "",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to update wishlist end date");
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleAddForm() {
|
||||
showAddForm = !showAddForm;
|
||||
if (showAddForm) {
|
||||
setTimeout(() => {
|
||||
addFormElement?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePositionChange(newPosition: number) {
|
||||
if (!editingItem) return;
|
||||
|
||||
const currentIndex = sortedItems.findIndex(item => item.id === editingItem.id);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
const newIndex = newPosition - 1; // Convert to 0-based index
|
||||
|
||||
// Reorder the array
|
||||
const newItems = [...sortedItems];
|
||||
const [movedItem] = newItems.splice(currentIndex, 1);
|
||||
newItems.splice(newIndex, 0, movedItem);
|
||||
|
||||
sortedItems = newItems;
|
||||
await handleReorder(newItems);
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageContainer maxWidth="4xl">
|
||||
<Navigation
|
||||
isAuthenticated={data.isAuthenticated}
|
||||
showDashboardLink={true}
|
||||
/>
|
||||
|
||||
<WishlistHeader
|
||||
wishlist={data.wishlist}
|
||||
onTitleUpdate={handleTitleUpdate}
|
||||
onDescriptionUpdate={handleDescriptionUpdate}
|
||||
onColorUpdate={handleColorUpdate}
|
||||
onEndDateUpdate={handleEndDateUpdate}
|
||||
/>
|
||||
|
||||
<ShareLinks
|
||||
publicUrl={data.publicUrl}
|
||||
ownerUrl="/wishlist/{data.wishlist.ownerToken}/edit"
|
||||
/>
|
||||
|
||||
<WishlistActionButtons
|
||||
bind:rearranging={rearranging}
|
||||
onToggleAddForm={handleToggleAddForm}
|
||||
/>
|
||||
|
||||
{#if showAddForm}
|
||||
<div bind:this={addFormElement}>
|
||||
<AddItemForm onSuccess={handleItemAdded} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingItem}
|
||||
<div bind:this={editFormElement}>
|
||||
<EditItemForm
|
||||
item={editingItem}
|
||||
onSuccess={handleItemUpdated}
|
||||
onCancel={cancelEditing}
|
||||
onColorChange={handleColorChange}
|
||||
currentPosition={sortedItems.findIndex(item => item.id === editingItem.id) + 1}
|
||||
totalItems={sortedItems.length}
|
||||
onPositionChange={handlePositionChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sortedItems.length > 5}
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search items..."
|
||||
bind:value={searchQuery}
|
||||
class="pl-9"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<EditableItemsList
|
||||
bind:items={filteredItems}
|
||||
{rearranging}
|
||||
onStartEditing={startEditing}
|
||||
onReorder={handleReorder}
|
||||
/>
|
||||
|
||||
<div class="mt-12 pt-8 border-t border-border space-y-4">
|
||||
<div class="flex flex-col md:flex-row gap-4 justify-between items-stretch md:items-center">
|
||||
<Button
|
||||
onclick={() => rearranging = !rearranging}
|
||||
variant={rearranging ? "default" : "outline"}
|
||||
class="w-full md:w-auto"
|
||||
>
|
||||
{#if rearranging}
|
||||
<Lock class="mr-2 h-4 w-4" />
|
||||
Lock Editing
|
||||
{:else}
|
||||
<LockOpen class="mr-2 h-4 w-4" />
|
||||
Unlock for Reordering & Deletion
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if rearranging}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/deleteWishlist"
|
||||
use:enhance={({ cancel }) => {
|
||||
if (
|
||||
!confirm(
|
||||
"Are you sure you want to delete this wishlist? This action cannot be undone.",
|
||||
)
|
||||
) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
return async ({ result }) => {
|
||||
if (result.type === "success") {
|
||||
window.location.href = "/dashboard";
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
class="w-full md:w-auto"
|
||||
>
|
||||
Delete Wishlist
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
Reference in New Issue
Block a user