initial production version

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

14
src/routes/+layout.svelte Normal file
View 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>

View 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
View 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>

View 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 });
}
};

View 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 });
};

View 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 };
}
};

View 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>

View 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
};
};

View 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>

View 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');
}
};

View 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>

View 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 };
}
};

View 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>

View 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 };
}
};

View 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>