initial production version
This commit is contained in:
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