style: format entire codebase with prettier

This commit is contained in:
Rasmus Q
2026-03-15 21:02:57 +00:00
parent 06c96f4b35
commit 6c73a7740c
93 changed files with 5334 additions and 4976 deletions

View File

@@ -1,15 +1,15 @@
@import "tailwindcss";
@import 'tailwindcss';
@import "tw-animate-css";
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
* {
transition:
background-color 1s ease,
background-image 1s ease,
color 1s ease,
border-color 1s ease;
transition:
background-color 1s ease,
background-image 1s ease,
color 1s ease,
border-color 1s ease;
}
:root {

16
src/app.d.ts vendored
View File

@@ -1,14 +1,14 @@
import type { Session } from '@auth/core/types';
declare global {
namespace App {
interface Locals {
session: Session | null;
}
interface PageData {
session: Session | null;
}
}
namespace App {
interface Locals {
session: Session | null;
}
interface PageData {
session: Session | null;
}
}
}
export {};

View File

@@ -1,21 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function() {
const theme = localStorage.getItem('theme') || 'system';
const isDark = theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
const theme = localStorage.getItem('theme') || 'system';
const isDark =
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) {
document.documentElement.classList.add('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -11,121 +11,116 @@ import { env } from '$env/dynamic/private';
import type { SvelteKitAuthConfig } from '@auth/sveltekit';
function Authentik(config: {
clientId: string;
clientSecret: string;
issuer: string;
clientId: string;
clientSecret: string;
issuer: string;
}): OAuthConfig<any> {
return {
id: 'authentik',
name: 'Authentik',
type: 'oidc',
clientId: config.clientId,
clientSecret: config.clientSecret,
issuer: config.issuer,
authorization: {
params: {
scope: 'openid email profile'
}
},
profile(profile) {
return {
id: profile.sub,
email: profile.email,
name: profile.name || profile.preferred_username,
image: profile.picture
};
}
};
return {
id: 'authentik',
name: 'Authentik',
type: 'oidc',
clientId: config.clientId,
clientSecret: config.clientSecret,
issuer: config.issuer,
authorization: {
params: {
scope: 'openid email profile'
}
},
profile(profile) {
return {
id: profile.sub,
email: profile.email,
name: profile.name || profile.preferred_username,
image: profile.picture
};
}
};
}
const authConfig: SvelteKitAuthConfig = {
adapter: DrizzleAdapter(db),
session: {
strategy: 'jwt'
},
providers: [
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? [
Google({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET
})
]
: []),
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
? [
Authentik({
clientId: env.AUTHENTIK_CLIENT_ID,
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
issuer: env.AUTHENTIK_ISSUER
})
]
: []),
Credentials({
id: 'credentials',
name: 'credentials',
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null;
}
adapter: DrizzleAdapter(db),
session: {
strategy: 'jwt'
},
providers: [
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? [
Google({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET
})
]
: []),
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
? [
Authentik({
clientId: env.AUTHENTIK_CLIENT_ID,
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
issuer: env.AUTHENTIK_ISSUER
})
]
: []),
Credentials({
id: 'credentials',
name: 'credentials',
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' }
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null;
}
const user = await db.query.users.findFirst({
where: eq(users.username, credentials.username as string)
});
const user = await db.query.users.findFirst({
where: eq(users.username, credentials.username as string)
});
if (!user || !user.password) {
return null;
}
if (!user || !user.password) {
return null;
}
const isValidPassword = await bcrypt.compare(
credentials.password as string,
user.password
);
const isValidPassword = await bcrypt.compare(credentials.password as string, user.password);
if (!isValidPassword) {
return null;
}
if (!isValidPassword) {
return null;
}
return {
id: user.id,
email: user.email || undefined,
name: user.name,
image: user.image
};
}
})
],
pages: {
signIn: '/signin'
},
callbacks: {
async signIn({ user }) {
if (user?.id) {
await db.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, user.id));
}
return true;
},
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
}
return session;
}
},
secret: env.AUTH_SECRET,
trustHost: env.AUTH_TRUST_HOST === 'true'
return {
id: user.id,
email: user.email || undefined,
name: user.name,
image: user.image
};
}
})
],
pages: {
signIn: '/signin'
},
callbacks: {
async signIn({ user }) {
if (user?.id) {
await db.update(users).set({ lastLogin: new Date() }).where(eq(users.id, user.id));
}
return true;
},
async jwt({ token, user }) {
if (user) {
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id as string;
}
return session;
}
},
secret: env.AUTH_SECRET,
trustHost: env.AUTH_TRUST_HOST === 'true'
};
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);

View File

@@ -1,158 +1,161 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import { getLocalWishlists, forgetLocalWishlist, toggleLocalFavorite, type LocalWishlist } from '$lib/utils/localWishlists';
import { languageStore } from '$lib/stores/language.svelte';
import { Star } from '@lucide/svelte';
import { onMount } from 'svelte';
import { Button } from '$lib/components/ui/button';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import {
getLocalWishlists,
forgetLocalWishlist,
toggleLocalFavorite,
type LocalWishlist
} from '$lib/utils/localWishlists';
import { languageStore } from '$lib/stores/language.svelte';
import { Star } from '@lucide/svelte';
import { onMount } from 'svelte';
let {
isAuthenticated = false,
fallbackColor = null,
fallbackTheme = null
}: {
isAuthenticated?: boolean;
fallbackColor?: string | null;
fallbackTheme?: string | null;
} = $props();
let {
isAuthenticated = false,
fallbackColor = null,
fallbackTheme = null
}: {
isAuthenticated?: boolean;
fallbackColor?: string | null;
fallbackTheme?: string | null;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
let localWishlists = $state<LocalWishlist[]>([]);
let enrichedWishlists = $state<any[]>([]);
let localWishlists = $state<LocalWishlist[]>([]);
let enrichedWishlists = $state<any[]>([]);
onMount(async () => {
localWishlists = getLocalWishlists();
onMount(async () => {
localWishlists = getLocalWishlists();
const promises = localWishlists.map(async (local) => {
try {
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
if (response.ok) {
const data = await response.json();
return {
...data,
isFavorite: local.isFavorite || false
};
}
} catch (error) {
console.error('Failed to fetch wishlist data:', error);
}
return {
id: local.ownerToken,
title: local.title,
ownerToken: local.ownerToken,
publicToken: local.publicToken,
createdAt: local.createdAt,
isFavorite: local.isFavorite || false,
items: [],
theme: null,
color: null
};
});
const promises = localWishlists.map(async (local) => {
try {
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
if (response.ok) {
const data = await response.json();
return {
...data,
isFavorite: local.isFavorite || false
};
}
} catch (error) {
console.error('Failed to fetch wishlist data:', error);
}
return {
id: local.ownerToken,
title: local.title,
ownerToken: local.ownerToken,
publicToken: local.publicToken,
createdAt: local.createdAt,
isFavorite: local.isFavorite || false,
items: [],
theme: null,
color: null
};
});
enrichedWishlists = await Promise.all(promises);
});
enrichedWishlists = await Promise.all(promises);
});
async function refreshEnrichedWishlists() {
const promises = localWishlists.map(async (local) => {
try {
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
if (response.ok) {
const data = await response.json();
return {
...data,
isFavorite: local.isFavorite || false
};
}
} catch (error) {
console.error('Failed to fetch wishlist data:', error);
}
return {
id: local.ownerToken,
title: local.title,
ownerToken: local.ownerToken,
publicToken: local.publicToken,
createdAt: local.createdAt,
isFavorite: local.isFavorite || false,
items: [],
theme: null,
color: null
};
});
async function refreshEnrichedWishlists() {
const promises = localWishlists.map(async (local) => {
try {
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
if (response.ok) {
const data = await response.json();
return {
...data,
isFavorite: local.isFavorite || false
};
}
} catch (error) {
console.error('Failed to fetch wishlist data:', error);
}
return {
id: local.ownerToken,
title: local.title,
ownerToken: local.ownerToken,
publicToken: local.publicToken,
createdAt: local.createdAt,
isFavorite: local.isFavorite || false,
items: [],
theme: null,
color: null
};
});
enrichedWishlists = await Promise.all(promises);
}
enrichedWishlists = await Promise.all(promises);
}
async function handleForget(ownerToken: string) {
forgetLocalWishlist(ownerToken);
localWishlists = getLocalWishlists();
await refreshEnrichedWishlists();
}
async function handleForget(ownerToken: string) {
forgetLocalWishlist(ownerToken);
localWishlists = getLocalWishlists();
await refreshEnrichedWishlists();
}
async function handleToggleFavorite(ownerToken: string) {
toggleLocalFavorite(ownerToken);
localWishlists = getLocalWishlists();
await refreshEnrichedWishlists();
}
async function handleToggleFavorite(ownerToken: string) {
toggleLocalFavorite(ownerToken);
localWishlists = getLocalWishlists();
await refreshEnrichedWishlists();
}
// Use enriched wishlists which have full data including theme and color
const transformedWishlists = $derived(() => enrichedWishlists);
// Use enriched wishlists which have full data including theme and color
const transformedWishlists = $derived(() => enrichedWishlists);
// Description depends on authentication status
const sectionDescription = $derived(() => {
if (isAuthenticated) {
return t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet.";
}
return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently.";
});
// Description depends on authentication status
const sectionDescription = $derived(() => {
if (isAuthenticated) {
return (
t.dashboard.localWishlistsAuthDescription ||
"Wishlists stored in your browser that haven't been claimed yet."
);
}
return (
t.dashboard.localWishlistsDescription ||
'Wishlists stored in your browser. Sign in to save them permanently.'
);
});
</script>
<WishlistSection
title={t.dashboard.localWishlists || "Local Wishlists"}
description={sectionDescription()}
items={transformedWishlists()}
emptyMessage={t.dashboard.emptyLocalWishlists || "No local wishlists yet"}
emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
emptyActionHref="/"
showCreateButton={true}
fallbackColor={fallbackColor}
fallbackTheme={fallbackTheme}
title={t.dashboard.localWishlists || 'Local Wishlists'}
description={sectionDescription()}
items={transformedWishlists()}
emptyMessage={t.dashboard.emptyLocalWishlists || 'No local wishlists yet'}
emptyActionLabel={t.dashboard.createLocalWishlist || 'Create local wishlist'}
emptyActionHref="/"
showCreateButton={true}
{fallbackColor}
{fallbackTheme}
>
{#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onclick={() => handleToggleFavorite(wishlist.ownerToken)}
>
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
</Button>
<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>
{#if unlocked}
<Button
size="sm"
variant="destructive"
onclick={() => handleForget(wishlist.ownerToken)}
>
{t.dashboard.forget || "Forget"}
</Button>
{/if}
</div>
{/snippet}
{#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onclick={() => handleToggleFavorite(wishlist.ownerToken)}>
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
</Button>
<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>
{#if unlocked}
<Button size="sm" variant="destructive" onclick={() => handleForget(wishlist.ownerToken)}>
{t.dashboard.forget || 'Forget'}
</Button>
{/if}
</div>
{/snippet}
</WishlistSection>

View File

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

View File

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

View File

@@ -1,166 +1,170 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
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';
import SearchBar from '$lib/components/ui/SearchBar.svelte';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import type { Snippet } from 'svelte';
import { Button } from '$lib/components/ui/button';
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';
import SearchBar from '$lib/components/ui/SearchBar.svelte';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import type { Snippet } from 'svelte';
type WishlistItem = any; // You can make this more specific based on your types
type WishlistItem = any; // You can make this more specific based on your types
let {
title,
description,
items,
emptyMessage,
emptyDescription,
emptyActionLabel,
emptyActionHref,
showCreateButton = false,
hideIfEmpty = false,
fallbackColor = null,
fallbackTheme = null,
actions
}: {
title: string;
description: string;
items: WishlistItem[];
emptyMessage: string;
emptyDescription?: string;
emptyActionLabel?: string;
emptyActionHref?: string;
showCreateButton?: boolean;
hideIfEmpty?: boolean;
fallbackColor?: string | null;
fallbackTheme?: string | null;
actions: Snippet<[WishlistItem, boolean]>; // item, unlocked
} = $props();
let {
title,
description,
items,
emptyMessage,
emptyDescription,
emptyActionLabel,
emptyActionHref,
showCreateButton = false,
hideIfEmpty = false,
fallbackColor = null,
fallbackTheme = null,
actions
}: {
title: string;
description: string;
items: WishlistItem[];
emptyMessage: string;
emptyDescription?: string;
emptyActionLabel?: string;
emptyActionHref?: string;
showCreateButton?: boolean;
hideIfEmpty?: boolean;
fallbackColor?: string | null;
fallbackTheme?: string | null;
actions: Snippet<[WishlistItem, boolean]>; // item, unlocked
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
let unlocked = $state(false);
let searchQuery = $state('');
let unlocked = $state(false);
let searchQuery = $state('');
// Filter items based on search query
const filteredItems = $derived(() => {
if (!searchQuery.trim()) return items;
// Filter items based on search query
const filteredItems = $derived(() => {
if (!searchQuery.trim()) return items;
return items.filter(item => {
const title = item.title || item.wishlist?.title || '';
const description = item.description || item.wishlist?.description || '';
const query = searchQuery.toLowerCase();
return items.filter((item) => {
const title = item.title || item.wishlist?.title || '';
const description = item.description || item.wishlist?.description || '';
const query = searchQuery.toLowerCase();
return title.toLowerCase().includes(query) || description.toLowerCase().includes(query);
});
});
return title.toLowerCase().includes(query) || description.toLowerCase().includes(query);
});
});
// Sort items by favorite, end date, then created date
const sortedItems = $derived(() => {
return [...filteredItems()].sort((a, b) => {
// Handle both direct wishlists and saved wishlists
const aItem = a.wishlist || a;
const bItem = b.wishlist || b;
// Sort items by favorite, end date, then created date
const sortedItems = $derived(() => {
return [...filteredItems()].sort((a, b) => {
// Handle both direct wishlists and saved wishlists
const aItem = a.wishlist || a;
const bItem = b.wishlist || b;
// Sort by favorite first
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
// Sort by favorite first
if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1;
// Then by end date
const aHasEndDate = !!aItem.endDate;
const bHasEndDate = !!bItem.endDate;
// Then by end date
const aHasEndDate = !!aItem.endDate;
const bHasEndDate = !!bItem.endDate;
if (aHasEndDate && !bHasEndDate) return -1;
if (!aHasEndDate && bHasEndDate) return 1;
if (aHasEndDate && !bHasEndDate) return -1;
if (!aHasEndDate && bHasEndDate) return 1;
if (aHasEndDate && bHasEndDate) {
return new Date(aItem.endDate!).getTime() - new Date(bItem.endDate!).getTime();
}
if (aHasEndDate && bHasEndDate) {
return new Date(aItem.endDate!).getTime() - new Date(bItem.endDate!).getTime();
}
// Finally by created date (most recent first)
const aCreatedAt = a.createdAt || aItem.createdAt;
const bCreatedAt = b.createdAt || bItem.createdAt;
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime();
});
});
// Finally by created date (most recent first)
const aCreatedAt = a.createdAt || aItem.createdAt;
const bCreatedAt = b.createdAt || bItem.createdAt;
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).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 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(item: any): string | null {
const wishlist = item.wishlist || item;
if (!wishlist) return null;
function getWishlistDescription(item: any): string | null {
const wishlist = item.wishlist || item;
if (!wishlist) return null;
const lines: string[] = [];
const lines: string[] = [];
const topItems = wishlist.items?.slice(0, 3).map((i: any) => i.title) || [];
if (topItems.length > 0) {
lines.push(topItems.join(', '));
}
const topItems = wishlist.items?.slice(0, 3).map((i: any) => i.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.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)}`);
}
if (wishlist.endDate) {
lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`);
}
return lines.length > 0 ? lines.join('\n') : null;
}
return lines.length > 0 ? lines.join('\n') : null;
}
// Hide entire section if hideIfEmpty is true and there are no items
const shouldShow = $derived(() => {
return !hideIfEmpty || items.length > 0;
});
// Hide entire section if hideIfEmpty is true and there are no items
const shouldShow = $derived(() => {
return !hideIfEmpty || items.length > 0;
});
</script>
{#if shouldShow()}
<WishlistGrid
{title}
{description}
items={sortedItems() || []}
{emptyMessage}
{emptyDescription}
{emptyActionLabel}
{emptyActionHref}
{fallbackColor}
{fallbackTheme}
>
{#snippet headerAction()}
<div class="flex flex-col sm:flex-row gap-2">
{#if showCreateButton}
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
{/if}
<UnlockButton bind:unlocked />
</div>
{/snippet}
<WishlistGrid
{title}
{description}
items={sortedItems() || []}
{emptyMessage}
{emptyDescription}
{emptyActionLabel}
{emptyActionHref}
{fallbackColor}
{fallbackTheme}
>
{#snippet headerAction()}
<div class="flex flex-col sm:flex-row gap-2">
{#if showCreateButton}
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
{/if}
<UnlockButton bind:unlocked />
</div>
{/snippet}
{#snippet searchBar()}
{#if items.length > 0}
<SearchBar bind:value={searchQuery} />
{/if}
{/snippet}
{#snippet searchBar()}
{#if items.length > 0}
<SearchBar bind:value={searchQuery} />
{/if}
{/snippet}
{#snippet children(item)}
{@const wishlist = item.wishlist || item}
<WishlistCard
title={wishlist.title}
description={getWishlistDescription(item)}
itemCount={wishlist.items?.length || 0}
color={wishlist.color}
theme={wishlist.theme}
fallbackColor={fallbackColor}
fallbackTheme={fallbackTheme}
>
{@render actions(item, unlocked)}
</WishlistCard>
{/snippet}
</WishlistGrid>
{#snippet children(item)}
{@const wishlist = item.wishlist || item}
<WishlistCard
title={wishlist.title}
description={getWishlistDescription(item)}
itemCount={wishlist.items?.length || 0}
color={wishlist.color}
theme={wishlist.theme}
{fallbackColor}
{fallbackTheme}
>
{@render actions(item, unlocked)}
</WishlistCard>
{/snippet}
</WishlistGrid>
{/if}

View File

@@ -1,92 +1,100 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { signOut } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
import { enhance } from '$app/forms';
import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { signOut } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
import { enhance } from '$app/forms';
let {
userName,
userEmail,
dashboardTheme = 'none',
dashboardColor = null,
isAuthenticated = false,
onThemeUpdate,
onColorUpdate
}: {
userName?: string | null;
userEmail?: string | null;
dashboardTheme?: string;
dashboardColor?: string | null;
isAuthenticated?: boolean;
onThemeUpdate?: (theme: string | null) => void;
onColorUpdate?: (color: string | null) => void;
} = $props();
let {
userName,
userEmail,
dashboardTheme = 'none',
dashboardColor = null,
isAuthenticated = false,
onThemeUpdate,
onColorUpdate
}: {
userName?: string | null;
userEmail?: string | null;
dashboardTheme?: string;
dashboardColor?: string | null;
isAuthenticated?: boolean;
onThemeUpdate?: (theme: string | null) => void;
onColorUpdate?: (color: string | null) => void;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
async function handleThemeChange(theme: string) {
if (onThemeUpdate) {
onThemeUpdate(theme);
}
async function handleThemeChange(theme: string) {
if (onThemeUpdate) {
onThemeUpdate(theme);
}
if (isAuthenticated) {
const formData = new FormData();
formData.append('theme', theme);
if (isAuthenticated) {
const formData = new FormData();
formData.append('theme', theme);
await fetch('?/updateDashboardTheme', {
method: 'POST',
body: formData
});
}
}
await fetch('?/updateDashboardTheme', {
method: 'POST',
body: formData
});
}
}
let localColor = $state(dashboardColor);
let localColor = $state(dashboardColor);
$effect(() => {
localColor = dashboardColor;
});
$effect(() => {
localColor = dashboardColor;
});
async function handleColorChange() {
if (onColorUpdate) {
onColorUpdate(localColor);
}
async function handleColorChange() {
if (onColorUpdate) {
onColorUpdate(localColor);
}
if (isAuthenticated) {
const formData = new FormData();
if (localColor) {
formData.append('color', localColor);
}
if (isAuthenticated) {
const formData = new FormData();
if (localColor) {
formData.append('color', localColor);
}
await fetch('?/updateDashboardColor', {
method: 'POST',
body: formData
});
}
}
await fetch('?/updateDashboardColor', {
method: 'POST',
body: formData
});
}
}
</script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
{#if isAuthenticated}
<p class="text-muted-foreground truncate">{t.dashboard.welcomeBack}, {userName || userEmail}</p>
{:else}
<p class="text-muted-foreground">{t.dashboard.anonymousDashboard || "Your local wishlists"}</p>
{/if}
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
<LanguageToggle color={localColor} />
<ThemeToggle />
{#if isAuthenticated}
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>
{:else}
<Button variant="outline" onclick={() => (window.location.href = '/signin')}>{t.auth.signIn}</Button>
{/if}
</div>
<div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
{#if isAuthenticated}
<p class="text-muted-foreground truncate">
{t.dashboard.welcomeBack}, {userName || userEmail}
</p>
{:else}
<p class="text-muted-foreground">
{t.dashboard.anonymousDashboard || 'Your local wishlists'}
</p>
{/if}
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
<LanguageToggle color={localColor} />
<ThemeToggle />
{#if isAuthenticated}
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}
>{t.auth.signOut}</Button
>
{:else}
<Button variant="outline" onclick={() => (window.location.href = '/signin')}
>{t.auth.signIn}</Button
>
{/if}
</div>
</div>

View File

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

View File

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

View File

@@ -1,36 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
import { hexToRgba } from '$lib/utils/colors';
import { themeStore } from '$lib/stores/theme.svelte';
import type { Snippet } from 'svelte';
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
import { hexToRgba } from '$lib/utils/colors';
import { themeStore } from '$lib/stores/theme.svelte';
let {
children,
maxWidth = '6xl',
theme = null,
themeColor = null
}: {
children: Snippet;
maxWidth?: string;
theme?: string | null;
themeColor?: string | null;
} = $props();
let {
children,
maxWidth = '6xl',
theme = null,
themeColor = null
}: {
children: Snippet;
maxWidth?: string;
theme?: string | null;
themeColor?: string | null;
} = $props();
const backgroundStyle = $derived.by(() => {
if (!themeColor) return '';
const backgroundStyle = $derived.by(() => {
if (!themeColor) return '';
const isDark = themeStore.getResolvedTheme() === 'dark';
const tintedColor = hexToRgba(themeColor, 0.15);
const isDark = themeStore.getResolvedTheme() === 'dark';
const tintedColor = hexToRgba(themeColor, 0.15);
return isDark
? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;`
: `background-color: ${tintedColor};`;
});
return isDark
? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;`
: `background-color: ${tintedColor};`;
});
</script>
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden" style={backgroundStyle}>
<ThemeBackground themeName={theme} color={themeColor} />
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
{@render children()}
</div>
<ThemeBackground themeName={theme} color={themeColor} />
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
{@render children()}
</div>
</div>

View File

@@ -1,33 +1,33 @@
<script lang="ts">
import TopPattern from './svgs/TopPattern.svelte';
import BottomPattern from './svgs/BottomPattern.svelte';
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
import { themeStore } from '$lib/stores/theme.svelte';
import TopPattern from './svgs/TopPattern.svelte';
import BottomPattern from './svgs/BottomPattern.svelte';
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
import { themeStore } from '$lib/stores/theme.svelte';
let {
themeName,
showTop = true,
showBottom = true,
color
}: {
themeName?: string | null;
showTop?: boolean;
showBottom?: boolean;
color?: string;
} = $props();
let {
themeName,
showTop = true,
showBottom = true,
color
}: {
themeName?: string | null;
showTop?: boolean;
showBottom?: boolean;
color?: string;
} = $props();
const theme = $derived(getTheme(themeName));
const patternColor = $derived.by(() => {
const isDark = themeStore.getResolvedTheme() === 'dark';
return isDark ? '#FFFFFF' : '#000000';
});
const theme = $derived(getTheme(themeName));
const patternColor = $derived.by(() => {
const isDark = themeStore.getResolvedTheme() === 'dark';
return isDark ? '#FFFFFF' : '#000000';
});
</script>
{#if theme.pattern !== 'none'}
{#if showTop}
<TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if}
{#if showBottom}
<BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if}
{#if showTop}
<TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if}
{#if showBottom}
<BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if}
{/if}

View File

@@ -1,25 +1,25 @@
<script lang="ts">
import CardPattern from './svgs/CardPattern.svelte';
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
import { themeStore } from '$lib/stores/theme.svelte';
import CardPattern from './svgs/CardPattern.svelte';
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
import { themeStore } from '$lib/stores/theme.svelte';
let {
themeName,
color,
showPattern = true
}: {
themeName?: string | null;
color?: string | null;
showPattern?: boolean;
} = $props();
let {
themeName,
color,
showPattern = true
}: {
themeName?: string | null;
color?: string | null;
showPattern?: boolean;
} = $props();
const theme = $derived(getTheme(themeName));
const patternColor = $derived.by(() => {
const isDark = themeStore.getResolvedTheme() === 'dark';
return isDark ? '#FFFFFF' : '#000000';
});
const theme = $derived(getTheme(themeName));
const patternColor = $derived.by(() => {
const isDark = themeStore.getResolvedTheme() === 'dark';
return isDark ? '#FFFFFF' : '#000000';
});
</script>
{#if showPattern && theme.pattern !== 'none'}
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if}

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import { asset } from '$app/paths';
import { asset } from '$app/paths';
let {
pattern = 'none',
color = '#000000',
opacity = 0.1
}: {
pattern?: string;
color?: string;
opacity?: number;
} = $props();
let {
pattern = 'none',
color = '#000000',
opacity = 0.1
}: {
pattern?: string;
color?: string;
opacity?: number;
} = $props();
const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`));
const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`));
</script>
{#if pattern !== 'none'}
<div
class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0"
style="
<div
class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0"
style="
mask-image: url({patternPath});
mask-size: cover;
mask-repeat: no-repeat;
@@ -26,5 +26,5 @@
opacity: {opacity};
height: 100vh;
"
/>
/>
{/if}

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import { asset } from '$app/paths';
import { asset } from '$app/paths';
let {
pattern = 'none',
color = '#000000',
opacity = 0.1
}: {
pattern?: string;
color?: string;
opacity?: number;
} = $props();
let {
pattern = 'none',
color = '#000000',
opacity = 0.1
}: {
pattern?: string;
color?: string;
opacity?: number;
} = $props();
const patternPath = $derived(asset(`/themes/${pattern}/item.svg`));
const patternPath = $derived(asset(`/themes/${pattern}/item.svg`));
</script>
{#if pattern !== 'none'}
<div
class="absolute bottom-0 top-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
style="
<div
class="absolute bottom-0 top-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
style="
mask-image: url({patternPath});
mask-size: cover;
mask-repeat: no-repeat;
@@ -26,5 +26,5 @@
opacity: {opacity};
width: 100%;
"
/>
/>
{/if}

View File

@@ -1,23 +1,23 @@
<script lang="ts">
import { asset } from '$app/paths';
import { asset } from '$app/paths';
let {
pattern = 'none',
color = '#000000',
opacity = 0.1
}: {
pattern?: string;
color?: string;
opacity?: number;
} = $props();
let {
pattern = 'none',
color = '#000000',
opacity = 0.1
}: {
pattern?: string;
color?: string;
opacity?: number;
} = $props();
const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`));
const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`));
</script>
{#if pattern !== 'none'}
<div
class="fixed top-0 right-0 left-0 pointer-events-none z-0"
style="
<div
class="fixed top-0 right-0 left-0 pointer-events-none z-0"
style="
mask-image: url({patternPath});
mask-size: cover;
mask-repeat: no-repeat;
@@ -26,5 +26,5 @@
opacity: {opacity};
height: 100vh;
"
/>
/>
{/if}

View File

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

View File

@@ -1,133 +1,133 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import type { Snippet } from 'svelte';
import IconButton from './IconButton.svelte';
import { Button } from '$lib/components/ui/button';
import { scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import type { Snippet } from 'svelte';
import IconButton from './IconButton.svelte';
let {
items,
selectedValue,
onSelect,
color,
showCheckmark = true,
icon,
ariaLabel
}: {
items: Array<{ value: string; label: string }>;
selectedValue: string;
onSelect: (value: string) => void;
color?: string | null;
showCheckmark?: boolean;
icon: Snippet;
ariaLabel: string;
} = $props();
let {
items,
selectedValue,
onSelect,
color,
showCheckmark = true,
icon,
ariaLabel
}: {
items: Array<{ value: string; label: string }>;
selectedValue: string;
onSelect: (value: string) => void;
color?: string | null;
showCheckmark?: boolean;
icon: Snippet;
ariaLabel: string;
} = $props();
let showMenu = $state(false);
let showMenu = $state(false);
const menuClasses = $derived(
color
? 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md'
: 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-950/90'
);
const menuClasses = $derived(
color
? 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md'
: 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-950/90'
);
const menuStyle = $derived(
color
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
: ''
);
const menuStyle = $derived(
color
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
: ''
);
function getItemStyle(itemValue: string): string {
if (!color) return '';
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
}
function getItemStyle(itemValue: string): string {
if (!color) return '';
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
}
function toggleMenu() {
showMenu = !showMenu;
}
function toggleMenu() {
showMenu = !showMenu;
}
function handleSelect(value: string) {
onSelect(value);
showMenu = false;
}
function handleSelect(value: string) {
onSelect(value);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-menu')) {
showMenu = false;
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-menu')) {
showMenu = false;
}
}
function handleMouseEnter(e: MouseEvent) {
if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
}
}
function handleMouseEnter(e: MouseEvent) {
if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
}
}
function handleMouseLeave(e: MouseEvent, itemValue: string) {
if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor =
selectedValue === itemValue ? `${color}20` : 'transparent';
}
}
function handleMouseLeave(e: MouseEvent, itemValue: string) {
if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor =
selectedValue === itemValue ? `${color}20` : 'transparent';
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="relative dropdown-menu">
<IconButton
size="sm"
rounded="md"
onclick={toggleMenu}
aria-label={ariaLabel}
class={color ? 'hover-themed' : ''}
style={color ? `--hover-bg: ${color}20;` : ''}
>
{@render icon()}
</IconButton>
<IconButton
size="sm"
rounded="md"
onclick={toggleMenu}
aria-label={ariaLabel}
class={color ? 'hover-themed' : ''}
style={color ? `--hover-bg: ${color}20;` : ''}
>
{@render icon()}
</IconButton>
{#if showMenu}
<div
class={menuClasses}
style={menuStyle}
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
>
<div class="py-1">
{#each items as item}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm transition-colors"
class:hover:bg-slate-100={!color}
class:dark:hover:bg-slate-900={!color}
class:font-bold={selectedValue === item.value}
class:bg-slate-100={selectedValue === item.value && !color}
class:dark:bg-slate-900={selectedValue === item.value && !color}
class:flex={showCheckmark}
class:items-center={showCheckmark}
class:justify-between={showCheckmark}
style={getItemStyle(item.value)}
onmouseenter={handleMouseEnter}
onmouseleave={(e) => handleMouseLeave(e, item.value)}
onclick={() => handleSelect(item.value)}
>
<span>{item.label}</span>
{#if showCheckmark && selectedValue === item.value}
<span class="ml-2"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
{#if showMenu}
<div
class={menuClasses}
style={menuStyle}
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
>
<div class="py-1">
{#each items as item}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm transition-colors"
class:hover:bg-slate-100={!color}
class:dark:hover:bg-slate-900={!color}
class:font-bold={selectedValue === item.value}
class:bg-slate-100={selectedValue === item.value && !color}
class:dark:bg-slate-900={selectedValue === item.value && !color}
class:flex={showCheckmark}
class:items-center={showCheckmark}
class:justify-between={showCheckmark}
style={getItemStyle(item.value)}
onmouseenter={handleMouseEnter}
onmouseleave={(e) => handleMouseLeave(e, item.value)}
onclick={() => handleSelect(item.value)}
>
<span>{item.label}</span>
{#if showCheckmark && selectedValue === item.value}
<span class="ml-2"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
:global(.hover-themed:hover) {
background-color: var(--hover-bg) !important;
}
:global(.hover-themed:hover) {
background-color: var(--hover-bg) !important;
}
</style>

View File

@@ -1,52 +1,53 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
color?: string | null;
rounded?: 'full' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg';
children: Snippet;
}
interface Props extends HTMLButtonAttributes {
color?: string | null;
rounded?: 'full' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg';
children: Snippet;
}
let {
color = null,
rounded = 'full',
size = 'md',
class: className = '',
children,
...restProps
}: Props = $props();
let {
color = null,
rounded = 'full',
size = 'md',
class: className = '',
children,
...restProps
}: Props = $props();
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12'
};
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12'
};
const roundedClasses = {
full: 'rounded-full',
md: 'rounded-md',
lg: 'rounded-lg'
};
const roundedClasses = {
full: 'rounded-full',
md: 'rounded-md',
lg: 'rounded-lg'
};
const baseClasses = 'flex items-center justify-center border border-input transition-colors backdrop-blur';
const sizeClass = sizeClasses[size];
const roundedClass = roundedClasses[rounded];
const baseClasses =
'flex items-center justify-center border border-input transition-colors backdrop-blur';
const sizeClass = sizeClasses[size];
const roundedClass = roundedClasses[rounded];
</script>
<button
type="button"
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
class:hover:bg-accent={!color}
style={color ? `--hover-bg: ${color}20;` : ''}
{...restProps}
type="button"
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
class:hover:bg-accent={!color}
style={color ? `--hover-bg: ${color}20;` : ''}
{...restProps}
>
{@render children()}
{@render children()}
</button>
<style>
button[style*='--hover-bg']:hover {
background-color: var(--hover-bg);
}
button[style*='--hover-bg']:hover {
background-color: var(--hover-bg);
}
</style>

View File

@@ -1,18 +1,14 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { languageStore } from '$lib/stores/language.svelte';
import { Input } from '$lib/components/ui/input';
import { languageStore } from '$lib/stores/language.svelte';
let {
value = $bindable(''),
placeholder = languageStore.t.dashboard.searchPlaceholder
}: {
value: string;
placeholder?: string;
} = $props();
let {
value = $bindable(''),
placeholder = languageStore.t.dashboard.searchPlaceholder
}: {
value: string;
placeholder?: string;
} = $props();
</script>
<Input
type="search"
{placeholder}
bind:value
/>
<Input type="search" {placeholder} bind:value />

View File

@@ -1,30 +1,27 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Lock, LockOpen } from '@lucide/svelte';
import { languageStore } from '$lib/stores/language.svelte';
import { Button } from '$lib/components/ui/button';
import { Lock, LockOpen } from '@lucide/svelte';
import { languageStore } from '$lib/stores/language.svelte';
let {
unlocked = $bindable(false)
}: {
unlocked: boolean;
} = $props();
let {
unlocked = $bindable(false)
}: {
unlocked: boolean;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
function handleClick() {
unlocked = !unlocked;
}
function handleClick() {
unlocked = !unlocked;
}
</script>
<Button
onclick={handleClick}
variant={unlocked ? "default" : "outline"}
>
{#if unlocked}
<Lock class="mr-2 h-4 w-4" />
{t.wishlist.lockDeletion}
{:else}
<LockOpen class="mr-2 h-4 w-4" />
{t.wishlist.unlockDeletion}
{/if}
<Button onclick={handleClick} variant={unlocked ? 'default' : 'outline'}>
{#if unlocked}
<Lock class="mr-2 h-4 w-4" />
{t.wishlist.lockDeletion}
{:else}
<LockOpen class="mr-2 h-4 w-4" />
{t.wishlist.unlockDeletion}
{/if}
</Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,32 +1,32 @@
<script lang="ts">
import { languageStore } from '$lib/stores/language.svelte';
import { languages } from '$lib/i18n/translations';
import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Languages } from '@lucide/svelte';
import { languageStore } from '$lib/stores/language.svelte';
import { languages } from '$lib/i18n/translations';
import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Languages } from '@lucide/svelte';
let { color }: { color?: string | null } = $props();
let { color }: { color?: string | null } = $props();
const languageItems = $derived(
languages.map((lang) => ({
value: lang.code,
label: lang.name
}))
);
const languageItems = $derived(
languages.map((lang) => ({
value: lang.code,
label: lang.name
}))
);
function setLanguage(code: string) {
languageStore.setLanguage(code as 'en' | 'da');
}
function setLanguage(code: string) {
languageStore.setLanguage(code as 'en' | 'da');
}
</script>
<Dropdown
items={languageItems}
selectedValue={languageStore.current}
onSelect={setLanguage}
{color}
showCheckmark={false}
ariaLabel="Toggle language"
items={languageItems}
selectedValue={languageStore.current}
onSelect={setLanguage}
{color}
showCheckmark={false}
ariaLabel="Toggle language"
>
{#snippet icon()}
<Languages class="h-[1.2rem] w-[1.2rem]" />
{/snippet}
{#snippet icon()}
<Languages class="h-[1.2rem] w-[1.2rem]" />
{/snippet}
</Dropdown>

View File

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

View File

@@ -1,35 +1,35 @@
<script lang="ts">
import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Palette } from '@lucide/svelte';
import { AVAILABLE_THEMES } from '$lib/utils/themes';
import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Palette } from '@lucide/svelte';
import { AVAILABLE_THEMES } from '$lib/utils/themes';
let {
value = 'none',
onValueChange,
color
}: {
value?: string;
onValueChange: (theme: string) => void;
color?: string | null;
} = $props();
let {
value = 'none',
onValueChange,
color
}: {
value?: string;
onValueChange: (theme: string) => void;
color?: string | null;
} = $props();
const themeItems = $derived(
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
value: key,
label: theme.name
}))
);
const themeItems = $derived(
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
value: key,
label: theme.name
}))
);
</script>
<Dropdown
items={themeItems}
selectedValue={value}
onSelect={onValueChange}
{color}
showCheckmark={true}
ariaLabel="Select theme pattern"
items={themeItems}
selectedValue={value}
onSelect={onValueChange}
{color}
showCheckmark={true}
ariaLabel="Select theme pattern"
>
{#snippet icon()}
<Palette class="h-[1.2rem] w-[1.2rem]" />
{/snippet}
{#snippet icon()}
<Palette class="h-[1.2rem] w-[1.2rem]" />
{/snippet}
</Dropdown>

View File

@@ -1,30 +1,30 @@
<script lang="ts">
import { themeStore } from '$lib/stores/theme.svelte';
import { Sun, Moon, Monitor } from '@lucide/svelte';
import IconButton from '../IconButton.svelte';
import { themeStore } from '$lib/stores/theme.svelte';
import { Sun, Moon, Monitor } from '@lucide/svelte';
import IconButton from '../IconButton.svelte';
let {
color = $bindable(null),
size = 'sm',
}: {
color: string | null;
size?: 'sm' | 'md' | 'lg';
} = $props();
let {
color = $bindable(null),
size = 'sm'
}: {
color: string | null;
size?: 'sm' | 'md' | 'lg';
} = $props();
function toggle() {
themeStore.toggle();
}
function toggle() {
themeStore.toggle();
}
</script>
<IconButton onclick={toggle} {size} {color} rounded="md">
{#if themeStore.current === 'light'}
<Sun size={20} />
<span class="sr-only">Light mode (click for dark)</span>
{:else if themeStore.current === 'dark'}
<Moon size={20} />
<span class="sr-only">Dark mode (click for system)</span>
{:else}
<Monitor size={20} />
<span class="sr-only">System mode (click for light)</span>
{/if}
{#if themeStore.current === 'light'}
<Sun size={20} />
<span class="sr-only">Light mode (click for dark)</span>
{:else if themeStore.current === 'dark'}
<Moon size={20} />
<span class="sr-only">Dark mode (click for system)</span>
{:else}
<Monitor size={20} />
<span class="sr-only">System mode (click for light)</span>
{/if}
</IconButton>

View File

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

View File

@@ -1,69 +1,63 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms';
import { languageStore } from '$lib/stores/language.svelte';
import { isLocalWishlist } from '$lib/utils/localWishlists';
import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms';
import { languageStore } from '$lib/stores/language.svelte';
import { isLocalWishlist } from '$lib/utils/localWishlists';
let {
isAuthenticated,
isOwner,
hasClaimed,
ownerToken
}: {
isAuthenticated: boolean;
isOwner: boolean;
hasClaimed: boolean;
ownerToken: string;
} = $props();
let {
isAuthenticated,
isOwner,
hasClaimed,
ownerToken
}: {
isAuthenticated: boolean;
isOwner: boolean;
hasClaimed: boolean;
ownerToken: string;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
// Check if this wishlist is in localStorage
const isLocal = $derived(isLocalWishlist(ownerToken));
// Check if this wishlist is in localStorage
const isLocal = $derived(isLocalWishlist(ownerToken));
</script>
{#if isAuthenticated}
<div class="mb-6">
{#if isOwner}
<Button
disabled
variant="outline"
class="w-full md:w-auto opacity-60 cursor-not-allowed"
>
{t.wishlist.youOwnThis}
</Button>
<p class="text-sm text-muted-foreground mt-2">
{t.wishlist.alreadyInDashboard}
</p>
{:else}
<form
method="POST"
action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"}
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
>
<Button
type="submit"
variant={hasClaimed ? "outline" : "default"}
class="w-full md:w-auto"
>
{hasClaimed ? "Unclaim Wishlist" : "Claim Wishlist"}
</Button>
</form>
<p class="text-sm text-muted-foreground mt-2">
{#if hasClaimed}
You have claimed this wishlist. It will appear in your dashboard.
{:else}
Claim this wishlist to add it to your dashboard for easy access.
{#if isLocal}
<br />
<span class="text-xs">It will remain in your local wishlists and also appear in your claimed wishlists.</span>
{/if}
{/if}
</p>
{/if}
</div>
<div class="mb-6">
{#if isOwner}
<Button disabled variant="outline" class="w-full md:w-auto opacity-60 cursor-not-allowed">
{t.wishlist.youOwnThis}
</Button>
<p class="text-sm text-muted-foreground mt-2">
{t.wishlist.alreadyInDashboard}
</p>
{:else}
<form
method="POST"
action={hasClaimed ? '?/unclaimWishlist' : '?/claimWishlist'}
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
>
<Button type="submit" variant={hasClaimed ? 'outline' : 'default'} class="w-full md:w-auto">
{hasClaimed ? 'Unclaim Wishlist' : 'Claim Wishlist'}
</Button>
</form>
<p class="text-sm text-muted-foreground mt-2">
{#if hasClaimed}
You have claimed this wishlist. It will appear in your dashboard.
{:else}
Claim this wishlist to add it to your dashboard for easy access.
{#if isLocal}
<br />
<span class="text-xs"
>It will remain in your local wishlists and also appear in your claimed wishlists.</span
>
{/if}
{/if}
</p>
{/if}
</div>
{/if}

View File

@@ -1,46 +1,42 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import { languageStore } from '$lib/stores/language.svelte';
import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import { languageStore } from '$lib/stores/language.svelte';
let {
unlocked = $bindable()
}: {
unlocked: boolean;
} = $props();
let {
unlocked = $bindable()
}: {
unlocked: boolean;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
</script>
<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">
<UnlockButton bind:unlocked />
<div class="flex flex-col md:flex-row gap-4 justify-between items-stretch md:items-center">
<UnlockButton bind:unlocked />
{#if unlocked}
<form
method="POST"
action="?/deleteWishlist"
use:enhance={({ cancel }) => {
if (!confirm(t.wishlist.deleteConfirm)) {
cancel();
return;
}
return async ({ result }) => {
if (result.type === "success") {
window.location.href = "/dashboard";
}
};
}}
>
<Button
type="submit"
variant="destructive"
class="w-full md:w-auto"
>
{t.wishlist.deleteWishlist}
</Button>
</form>
{/if}
</div>
{#if unlocked}
<form
method="POST"
action="?/deleteWishlist"
use:enhance={({ cancel }) => {
if (!confirm(t.wishlist.deleteConfirm)) {
cancel();
return;
}
return async ({ result }) => {
if (result.type === 'success') {
window.location.href = '/dashboard';
}
};
}}
>
<Button type="submit" variant="destructive" class="w-full md:w-auto">
{t.wishlist.deleteWishlist}
</Button>
</form>
{/if}
</div>
</div>

View File

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

View File

@@ -1,83 +1,69 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from "$lib/components/ui/card";
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
import EmptyState from "$lib/components/layout/EmptyState.svelte";
import type { Item } from "$lib/server/schema";
import { enhance } from "$app/forms";
import { flip } from "svelte/animate";
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
import { getCardStyle } from "$lib/utils/colors";
import { Button } from '$lib/components/ui/button';
import { Card, CardContent } from '$lib/components/ui/card';
import WishlistItem from '$lib/components/wishlist/WishlistItem.svelte';
import EmptyState from '$lib/components/layout/EmptyState.svelte';
import type { Item } from '$lib/server/schema';
import { enhance } from '$app/forms';
import { flip } from 'svelte/animate';
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { getCardStyle } from '$lib/utils/colors';
let {
items = $bindable([]),
rearranging,
onStartEditing,
onReorder,
theme = null,
wishlistColor = null
}: {
items: Item[];
rearranging: boolean;
onStartEditing: (item: Item) => void;
onReorder: (items: Item[]) => Promise<void>;
theme?: string | null;
wishlistColor?: string | null;
} = $props();
let {
items = $bindable([]),
rearranging,
onStartEditing,
onReorder,
theme = null,
wishlistColor = null
}: {
items: Item[];
rearranging: boolean;
onStartEditing: (item: Item) => void;
onReorder: (items: Item[]) => Promise<void>;
theme?: string | null;
wishlistColor?: string | null;
} = $props();
const t = $derived(languageStore.t);
const cardStyle = $derived(getCardStyle(wishlistColor));
const t = $derived(languageStore.t);
const cardStyle = $derived(getCardStyle(wishlistColor));
</script>
<div class="space-y-4">
{#if items && items.length > 0}
<div class="space-y-4">
{#each items as item (item.id)}
<div animate:flip={{ duration: 300 }}>
<WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onclick={() => onStartEditing(item)}
>
{t.wishlist.edit}
</Button>
{#if rearranging}
<form
method="POST"
action="?/deleteItem"
use:enhance
>
<input
type="hidden"
name="itemId"
value={item.id}
/>
<Button
type="submit"
variant="destructive"
size="sm"
>
{t.form.delete}
</Button>
</form>
{/if}
</div>
</WishlistItem>
</div>
{/each}
</div>
{:else}
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
<CardContent class="p-12 relative z-10">
<EmptyState
message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"}
/>
</CardContent>
</Card>
{/if}
{#if items && items.length > 0}
<div class="space-y-4">
{#each items as item (item.id)}
<div animate:flip={{ duration: 300 }}>
<WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}>
<div class="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onclick={() => onStartEditing(item)}
>
{t.wishlist.edit}
</Button>
{#if rearranging}
<form method="POST" action="?/deleteItem" use:enhance>
<input type="hidden" name="itemId" value={item.id} />
<Button type="submit" variant="destructive" size="sm">
{t.form.delete}
</Button>
</form>
{/if}
</div>
</WishlistItem>
</div>
{/each}
</div>
{:else}
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
<CardContent class="p-12 relative z-10">
<EmptyState message={t.wishlist.noWishes + '. ' + t.wishlist.addFirstWish + '!'} />
</CardContent>
</Card>
{/if}
</div>

View File

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

View File

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

View File

@@ -1,64 +1,66 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Card, CardContent } from '$lib/components/ui/card';
import { languageStore } from '$lib/stores/language.svelte';
import { getCardStyle } from '$lib/utils/colors';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Card, CardContent } from '$lib/components/ui/card';
import { languageStore } from '$lib/stores/language.svelte';
import { getCardStyle } from '$lib/utils/colors';
interface Props {
publicUrl: string;
ownerUrl?: string;
wishlistColor?: string | null;
}
interface Props {
publicUrl: string;
ownerUrl?: string;
wishlistColor?: string | null;
}
let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props();
let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props();
const t = $derived(languageStore.t);
const cardStyle = $derived(getCardStyle(null, wishlistColor));
const t = $derived(languageStore.t);
const cardStyle = $derived(getCardStyle(null, wishlistColor));
let copiedPublic = $state(false);
let copiedOwner = $state(false);
let copiedPublic = $state(false);
let copiedOwner = $state(false);
const publicLink = $derived(
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
);
const ownerLink = $derived(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : '');
const publicLink = $derived(
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
);
const ownerLink = $derived(
ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : ''
);
async function copyToClipboard(text: string, type: 'public' | 'owner') {
await navigator.clipboard.writeText(text);
if (type === 'public') {
copiedPublic = true;
setTimeout(() => (copiedPublic = false), 2000);
} else {
copiedOwner = true;
setTimeout(() => (copiedOwner = false), 2000);
}
}
async function copyToClipboard(text: string, type: 'public' | 'owner') {
await navigator.clipboard.writeText(text);
if (type === 'public') {
copiedPublic = true;
setTimeout(() => (copiedPublic = false), 2000);
} else {
copiedOwner = true;
setTimeout(() => (copiedOwner = false), 2000);
}
}
</script>
<Card style={cardStyle}>
<CardContent class="space-y-4 pt-6">
<div class="space-y-2">
<Label>{t.wishlist.shareViewOnly}</Label>
<div class="flex gap-2">
<Input readonly value={publicLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
{copiedPublic ? t.wishlist.copied : t.wishlist.copy}
</Button>
</div>
</div>
<CardContent class="space-y-4 pt-6">
<div class="space-y-2">
<Label>{t.wishlist.shareViewOnly}</Label>
<div class="flex gap-2">
<Input readonly value={publicLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
{copiedPublic ? t.wishlist.copied : t.wishlist.copy}
</Button>
</div>
</div>
{#if ownerLink}
<div class="space-y-2">
<Label>{t.wishlist.shareEditLink}</Label>
<div class="flex gap-2">
<Input readonly value={ownerLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
{copiedOwner ? t.wishlist.copied : t.wishlist.copy}
</Button>
</div>
</div>
{/if}
</CardContent>
{#if ownerLink}
<div class="space-y-2">
<Label>{t.wishlist.shareEditLink}</Label>
<div class="flex gap-2">
<Input readonly value={ownerLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
{copiedOwner ? t.wishlist.copied : t.wishlist.copy}
</Button>
</div>
</div>
{/if}
</CardContent>
</Card>

View File

@@ -1,25 +1,22 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { languageStore } from '$lib/stores/language.svelte';
import { Button } from '$lib/components/ui/button';
import { languageStore } from '$lib/stores/language.svelte';
let {
rearranging = $bindable(false),
showAddForm = false,
onToggleAddForm
}: {
rearranging: boolean;
showAddForm?: boolean;
onToggleAddForm: () => void;
} = $props();
let {
rearranging = $bindable(false),
showAddForm = false,
onToggleAddForm
}: {
rearranging: boolean;
showAddForm?: boolean;
onToggleAddForm: () => void;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
</script>
<div class="flex flex-col md:flex-row gap-4">
<Button
onclick={onToggleAddForm}
class="w-full md:w-auto"
>
{showAddForm ? t.form.cancel : t.wishlist.addWish}
</Button>
<Button onclick={onToggleAddForm} class="w-full md:w-auto">
{showAddForm ? t.form.cancel : t.wishlist.addWish}
</Button>
</div>

View File

@@ -1,212 +1,212 @@
<script lang="ts">
import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from "$lib/components/ui/input";
import { Label } from "$lib/components/ui/label";
import { Textarea } from "$lib/components/ui/textarea";
import { Pencil, Check, X } from "@lucide/svelte";
import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
import ThemePicker from "$lib/components/ui/theme-picker.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
import type { Wishlist } from "$lib/db/schema";
import { languageStore } from '$lib/stores/language.svelte';
import { getCardStyle } from '$lib/utils/colors';
import { Card, CardContent } from '$lib/components/ui/card';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Pencil, Check, X } from '@lucide/svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import type { Wishlist } from '$lib/db/schema';
import { languageStore } from '$lib/stores/language.svelte';
import { getCardStyle } from '$lib/utils/colors';
let {
wishlist,
onTitleUpdate,
onDescriptionUpdate,
onColorUpdate,
onEndDateUpdate,
onThemeUpdate
}: {
wishlist: Wishlist;
onTitleUpdate: (title: string) => Promise<boolean>;
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
onColorUpdate: (color: string | null) => void;
onEndDateUpdate: (endDate: string | null) => void;
onThemeUpdate: (theme: string | null) => void;
} = $props();
let {
wishlist,
onTitleUpdate,
onDescriptionUpdate,
onColorUpdate,
onEndDateUpdate,
onThemeUpdate
}: {
wishlist: Wishlist;
onTitleUpdate: (title: string) => Promise<boolean>;
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
onColorUpdate: (color: string | null) => void;
onEndDateUpdate: (endDate: string | null) => void;
onThemeUpdate: (theme: string | null) => void;
} = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
let editingTitle = $state(false);
let editingDescription = $state(false);
let wishlistTitle = $state(wishlist.title);
let wishlistDescription = $state(wishlist.description || "");
let wishlistColor = $state<string | null>(wishlist.color);
let wishlistTheme = $state<string>(wishlist.theme || 'none');
let wishlistEndDate = $state<string | null>(
wishlist.endDate
? new Date(wishlist.endDate).toISOString().split("T")[0]
: null,
);
let editingTitle = $state(false);
let editingDescription = $state(false);
let wishlistTitle = $state(wishlist.title);
let wishlistDescription = $state(wishlist.description || '');
let wishlistColor = $state<string | null>(wishlist.color);
let wishlistTheme = $state<string>(wishlist.theme || 'none');
let wishlistEndDate = $state<string | null>(
wishlist.endDate ? new Date(wishlist.endDate).toISOString().split('T')[0] : null
);
const cardStyle = $derived(getCardStyle(null, wishlistColor));
const cardStyle = $derived(getCardStyle(null, wishlistColor));
async function saveTitle() {
if (!wishlistTitle.trim()) {
wishlistTitle = wishlist.title;
editingTitle = false;
return;
}
async function saveTitle() {
if (!wishlistTitle.trim()) {
wishlistTitle = wishlist.title;
editingTitle = false;
return;
}
const success = await onTitleUpdate(wishlistTitle.trim());
if (success) {
editingTitle = false;
} else {
wishlistTitle = wishlist.title;
editingTitle = false;
}
}
const success = await onTitleUpdate(wishlistTitle.trim());
if (success) {
editingTitle = false;
} else {
wishlistTitle = wishlist.title;
editingTitle = false;
}
}
async function saveDescription() {
const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
if (success) {
editingDescription = false;
} else {
wishlistDescription = wishlist.description || "";
editingDescription = false;
}
}
async function saveDescription() {
const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
if (success) {
editingDescription = false;
} else {
wishlistDescription = wishlist.description || '';
editingDescription = false;
}
}
function handleEndDateChange(e: Event) {
const input = e.target as HTMLInputElement;
wishlistEndDate = input.value || null;
onEndDateUpdate(wishlistEndDate);
}
function handleEndDateChange(e: Event) {
const input = e.target as HTMLInputElement;
wishlistEndDate = input.value || null;
onEndDateUpdate(wishlistEndDate);
}
function clearEndDate() {
wishlistEndDate = null;
onEndDateUpdate(null);
}
function clearEndDate() {
wishlistEndDate = null;
onEndDateUpdate(null);
}
</script>
<div class="flex items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-2 flex-1 min-w-0">
{#if editingTitle}
<Input
bind:value={wishlistTitle}
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
onkeydown={(e) => {
if (e.key === "Enter") {
saveTitle();
} else if (e.key === "Escape") {
wishlistTitle = wishlist.title;
editingTitle = false;
}
}}
autofocus
/>
{:else}
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
{/if}
<IconButton
onclick={() => {
if (editingTitle) {
saveTitle();
} else {
editingTitle = true;
}
}}
color={wishlistColor}
size="sm"
class="shrink-0"
aria-label={editingTitle ? "Save title" : "Edit title"}
>
{#if editingTitle}
<Check class="w-4 h-4" />
{:else}
<Pencil class="w-4 h-4" />
{/if}
</IconButton>
</div>
<div class="flex items-center gap-2 shrink-0">
<ThemePicker
value={wishlistTheme}
onValueChange={async (theme) => {
wishlistTheme = theme;
onThemeUpdate(theme);
// Force reactivity by updating the wishlist object
wishlist.theme = theme;
}}
color={wishlistColor}
/>
<ColorPicker
bind:color={wishlistColor}
onchange={() => onColorUpdate(wishlistColor)}
size="sm"
/>
</div>
<div class="flex items-center gap-2 flex-1 min-w-0">
{#if editingTitle}
<Input
bind:value={wishlistTitle}
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
onkeydown={(e) => {
if (e.key === 'Enter') {
saveTitle();
} else if (e.key === 'Escape') {
wishlistTitle = wishlist.title;
editingTitle = false;
}
}}
autofocus
/>
{:else}
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
{/if}
<IconButton
onclick={() => {
if (editingTitle) {
saveTitle();
} else {
editingTitle = true;
}
}}
color={wishlistColor}
size="sm"
class="shrink-0"
aria-label={editingTitle ? 'Save title' : 'Edit title'}
>
{#if editingTitle}
<Check class="w-4 h-4" />
{:else}
<Pencil class="w-4 h-4" />
{/if}
</IconButton>
</div>
<div class="flex items-center gap-2 shrink-0">
<ThemePicker
value={wishlistTheme}
onValueChange={async (theme) => {
wishlistTheme = theme;
onThemeUpdate(theme);
// Force reactivity by updating the wishlist object
wishlist.theme = theme;
}}
color={wishlistColor}
/>
<ColorPicker
bind:color={wishlistColor}
onchange={() => onColorUpdate(wishlistColor)}
size="sm"
/>
</div>
</div>
<Card style={cardStyle}>
<CardContent class="pt-6 space-y-4">
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="wishlist-description">{t.form.descriptionOptional}</Label>
<IconButton
onclick={() => {
if (editingDescription) {
saveDescription();
} else {
editingDescription = true;
}
}}
color={wishlistColor}
size="sm"
class="flex-shrink-0"
aria-label={editingDescription ? "Save description" : "Edit description"}
>
{#if editingDescription}
<Check class="w-4 h-4" />
{:else}
<Pencil class="w-4 h-4" />
{/if}
</IconButton>
</div>
{#if editingDescription}
<Textarea
id="wishlist-description"
bind:value={wishlistDescription}
class="w-full"
rows={3}
onkeydown={(e) => {
if (e.key === "Escape") {
wishlistDescription = wishlist.description || "";
editingDescription = false;
}
}}
autofocus
/>
{:else}
<div class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]">
{wishlistDescription || t.form.noDescription}
</div>
{/if}
</div>
<CardContent class="pt-6 space-y-4">
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="wishlist-description">{t.form.descriptionOptional}</Label>
<IconButton
onclick={() => {
if (editingDescription) {
saveDescription();
} else {
editingDescription = true;
}
}}
color={wishlistColor}
size="sm"
class="flex-shrink-0"
aria-label={editingDescription ? 'Save description' : 'Edit description'}
>
{#if editingDescription}
<Check class="w-4 h-4" />
{:else}
<Pencil class="w-4 h-4" />
{/if}
</IconButton>
</div>
{#if editingDescription}
<Textarea
id="wishlist-description"
bind:value={wishlistDescription}
class="w-full"
rows={3}
onkeydown={(e) => {
if (e.key === 'Escape') {
wishlistDescription = wishlist.description || '';
editingDescription = false;
}
}}
autofocus
/>
{:else}
<div
class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]"
>
{wishlistDescription || t.form.noDescription}
</div>
{/if}
</div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
<div class="flex items-center gap-2">
{#if wishlistEndDate}
<IconButton
onclick={clearEndDate}
color={wishlistColor}
size="sm"
class="flex-shrink-0"
aria-label="Clear end date"
>
<X class="w-4 h-4" />
</IconButton>
{/if}
<Input
id="wishlist-end-date"
type="date"
value={wishlistEndDate || ""}
onchange={handleEndDateChange}
class="w-full sm:w-auto"
/>
</div>
</div>
</CardContent>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
<div class="flex items-center gap-2">
{#if wishlistEndDate}
<IconButton
onclick={clearEndDate}
color={wishlistColor}
size="sm"
class="flex-shrink-0"
aria-label="Clear end date"
>
<X class="w-4 h-4" />
</IconButton>
{/if}
<Input
id="wishlist-end-date"
type="date"
value={wishlistEndDate || ''}
onchange={handleEndDateChange}
class="w-full sm:w-auto"
/>
</div>
</div>
</CardContent>
</Card>

View File

@@ -1,125 +1,125 @@
<script lang="ts">
import { Card, CardContent } from "$lib/components/ui/card";
import type { Item } from "$lib/db/schema";
import { GripVertical, ExternalLink } from "@lucide/svelte";
import { getCardStyle } from '$lib/utils/colors';
import { Button } from "$lib/components/ui/button";
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { Card, CardContent } from '$lib/components/ui/card';
import type { Item } from '$lib/db/schema';
import { GripVertical, ExternalLink } from '@lucide/svelte';
import { getCardStyle } from '$lib/utils/colors';
import { Button } from '$lib/components/ui/button';
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
interface Props {
item: Item;
showImage?: boolean;
children?: any;
showDragHandle?: boolean;
theme?: string | null;
wishlistColor?: string | null;
}
interface Props {
item: Item;
showImage?: boolean;
children?: any;
showDragHandle?: boolean;
theme?: string | null;
wishlistColor?: string | null;
}
let {
item,
showImage = true,
children,
showDragHandle = false,
theme = null,
wishlistColor = null
}: Props = $props();
let {
item,
showImage = true,
children,
showDragHandle = false,
theme = null,
wishlistColor = null
}: Props = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
const currencySymbols: Record<string, string> = {
DKK: "kr",
EUR: "€",
USD: "$",
SEK: "kr",
NOK: "kr",
GBP: "£",
};
const currencySymbols: Record<string, string> = {
DKK: 'kr',
EUR: '€',
USD: '$',
SEK: 'kr',
NOK: 'kr',
GBP: '£'
};
function formatPrice(
price: string | null,
currency: string | null,
): string {
if (!price) return "";
const symbol = currency ? currencySymbols[currency] || currency : "kr";
const amount = parseFloat(price).toFixed(2);
function formatPrice(price: string | null, currency: string | null): string {
if (!price) return '';
const symbol = currency ? currencySymbols[currency] || currency : 'kr';
const amount = parseFloat(price).toFixed(2);
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && ["DKK", "SEK", "NOK"].includes(currency)) {
return `${amount} ${symbol}`;
}
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && ['DKK', 'SEK', 'NOK'].includes(currency)) {
return `${amount} ${symbol}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}
const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
</script>
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={item.color} showPattern={false} />
<CardContent class="p-6 relative z-10">
<div class="flex gap-4">
{#if showDragHandle}
<div
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
aria-label="Drag to reorder"
role="button"
tabindex="0"
style="touch-action: none;"
>
<GripVertical class="w-6 h-6 text-muted-foreground" />
</div>
{/if}
<ThemeCard themeName={theme} color={item.color} showPattern={false} />
<CardContent class="p-6 relative z-10">
<div class="flex gap-4">
{#if showDragHandle}
<div
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
aria-label="Drag to reorder"
role="button"
tabindex="0"
style="touch-action: none;"
>
<GripVertical class="w-6 h-6 text-muted-foreground" />
</div>
{/if}
<div class="flex flex-col md:flex-row gap-4 flex-1">
{#if showImage && item.imageUrl}
<img
src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}"
alt={item.title}
class="w-full md:w-32 h-32 object-cover rounded-lg"
onerror={(e) => e.currentTarget.src = item.imageUrl}
/>
{/if}
<div class="flex flex-col md:flex-row gap-4 flex-1">
{#if showImage && item.imageUrl}
<img
src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}"
alt={item.title}
class="w-full md:w-32 h-32 object-cover rounded-lg"
onerror={(e) => (e.currentTarget.src = item.imageUrl)}
/>
{/if}
<div class="flex-1 items-center min-w-0">
<div class="flex-1">
<h3 class="font-semibold text-lg break-words">{item.title}</h3>
</div>
<div class="flex-1 items-center min-w-0">
<div class="flex-1">
<h3 class="font-semibold text-lg break-words">{item.title}</h3>
</div>
{#if item.description}
<p class="text-muted-foreground break-words whitespace-pre-wrap" style="overflow-wrap: anywhere;">{item.description}</p>
{/if}
{#if item.description}
<p
class="text-muted-foreground break-words whitespace-pre-wrap"
style="overflow-wrap: anywhere;"
>
{item.description}
</p>
{/if}
<div class="flex flex-wrap gap-2 items-center text-sm mt-2">
{#if item.price}
<span class="font-medium"
>{formatPrice(item.price, item.currency)}</span
>
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center text-sm mt-2">
{#if item.price}
<span class="font-medium">{formatPrice(item.price, item.currency)}</span>
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center mt-3">
{#if item.link}
<Button
href={item.link}
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="sm"
class="gap-1.5"
>
<ExternalLink class="w-4 h-4" />
{t.wishlist.viewProduct}
</Button>
{/if}
<div class="flex flex-wrap gap-2 items-center mt-3">
{#if item.link}
<Button
href={item.link}
target="_blank"
rel="noopener noreferrer"
variant="outline"
size="sm"
class="gap-1.5"
>
<ExternalLink class="w-4 h-4" />
{t.wishlist.viewProduct}
</Button>
{/if}
{#if children}
{@render children()}
{/if}
</div>
</div>
</div>
</div>
</CardContent>
{#if children}
{@render children()}
{/if}
</div>
</div>
</div>
</div>
</CardContent>
</Card>

View File

@@ -4,167 +4,175 @@ import { createId } from '@paralleldrive/cuid2';
import type { AdapterAccountType } from '@auth/core/adapters';
export const users = pgTable('user', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password').notNull(),
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'),
lastLogin: timestamp('last_login', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password').notNull(),
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'),
lastLogin: timestamp('last_login', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
);
export const wishlists = pgTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
theme: text('theme').default('none'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
theme: text('theme').default('none'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
}));
export const items = pgTable('items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const itemsRelations = relations(items, ({ one, many }) => ({
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
}));
export const reservations = pgTable('reservations', {
id: text('id').primaryKey().$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const reservationsRelations = relations(reservations, ({ one }) => ({
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
}),
user: one(users, {
fields: [reservations.userId],
references: [users.id]
})
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
}),
user: one(users, {
fields: [reservations.userId],
references: [users.id]
})
}));
export const savedWishlists = pgTable('saved_wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
ownerToken: text('owner_token'), // Stores the owner token if user has edit access (claimed via edit link)
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
ownerToken: text('owner_token'), // Stores the owner token if user has edit access (claimed via edit link)
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
}));
export const usersRelations = relations(users, ({ many }) => ({
wishlists: many(wishlists),
savedWishlists: many(savedWishlists),
reservations: many(reservations)
wishlists: many(wishlists),
savedWishlists: many(savedWishlists),
reservations: many(reservations)
}));
export type User = typeof users.$inferSelect;

View File

@@ -2,162 +2,166 @@ import type { Translation } from './en';
// Danish translations
export const da: Translation = {
// Navigation
nav: {
dashboard: 'Dashboard'
},
// Navigation
nav: {
dashboard: 'Dashboard'
},
// Dashboard
dashboard: {
myWishlists: 'Mine Ønskelister',
myWishlistsDescription: 'Ønskelister du ejer og administrerer',
claimedWishlists: 'Ejede Ønskelister',
claimedWishlistsDescription: 'Ønskelister du har taget ejerskab af og kan redigere',
savedWishlists: 'Gemte Ønskelister',
savedWishlistsDescription: 'Ønskelister du følger',
createNew: '+ Opret Ny',
manage: 'Administrer',
copyLink: 'Kopiér Link',
viewWishlist: 'Se Ønskeliste',
unsave: 'Fjern',
unclaim: 'Fjern Ejerskab',
delete: 'Slet',
emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.',
emptyWishlistsAction: 'Opret Din Første Ønskeliste',
emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.',
emptyClaimedWishlistsDescription: 'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
emptySavedWishlistsDescription: 'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
by: 'af',
ends: 'Slutter',
welcomeBack: 'Velkommen tilbage',
searchPlaceholder: 'Søg ønsker...'
},
// Dashboard
dashboard: {
myWishlists: 'Mine Ønskelister',
myWishlistsDescription: 'Ønskelister du ejer og administrerer',
claimedWishlists: 'Ejede Ønskelister',
claimedWishlistsDescription: 'Ønskelister du har taget ejerskab af og kan redigere',
savedWishlists: 'Gemte Ønskelister',
savedWishlistsDescription: 'Ønskelister du følger',
createNew: '+ Opret Ny',
manage: 'Administrer',
copyLink: 'Kopiér Link',
viewWishlist: 'Se Ønskeliste',
unsave: 'Fjern',
unclaim: 'Fjern Ejerskab',
delete: 'Slet',
emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.',
emptyWishlistsAction: 'Opret Din Første Ønskeliste',
emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.',
emptyClaimedWishlistsDescription:
'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
emptySavedWishlistsDescription:
'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
by: 'af',
ends: 'Slutter',
welcomeBack: 'Velkommen tilbage',
searchPlaceholder: 'Søg ønsker...'
},
// Wishlist
wishlist: {
title: 'Ønskeliste',
createTitle: 'Opret Din Ønskeliste',
createDescription: 'Opret en ønskeliste og del den med venner og familie',
addWish: '+ Tilføj Ønske',
editWish: 'Rediger Ønske',
deleteWish: 'Slet Ønske',
reserve: 'Reservér',
unreserve: 'Fjern Reservation',
reserved: 'Reserveret',
reservedBy: 'af',
save: 'Gem',
saveWishlist: 'Gem Ønskeliste',
unsaveWishlist: 'Fjern',
share: 'Del',
edit: 'Rediger',
back: 'Tilbage',
noWishes: 'Ingen ønsker endnu',
addFirstWish: 'Tilføj dit første ønske',
emptyWishes: 'Denne ønskeliste har ingen ønsker endnu.',
viewProduct: 'Se Produkt',
claimWishlist: 'Tag Ejerskab Af Ønskeliste',
unclaimWishlist: 'Fjern Ejerskab Af Ønskeliste',
youOwnThis: 'Du Ejer Denne Ønskeliste',
youClaimedThis: 'Du Har Taget Ejerskab Af Denne Ønskeliste',
alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.',
alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.',
claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard',
claimedDescription: 'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
deleteWishlist: 'Slet Ønskeliste',
deleteConfirm: 'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
lockDeletion: 'Lås Sletning',
unlockDeletion: 'Lås Op for Sletning',
shareViewOnly: 'Del med venner (afslører reservationer)',
shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)',
copy: 'Kopiér',
copied: 'Kopieret!',
signInToSave: 'Log ind for at gemme',
saveThisWishlist: 'Gem Denne Ønskeliste',
saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard',
creating: 'Opretter...',
createWishlist: 'Opret Ønskeliste'
},
// Wishlist
wishlist: {
title: 'Ønskeliste',
createTitle: 'Opret Din Ønskeliste',
createDescription: 'Opret en ønskeliste og del den med venner og familie',
addWish: '+ Tilføj Ønske',
editWish: 'Rediger Ønske',
deleteWish: 'Slet Ønske',
reserve: 'Reservér',
unreserve: 'Fjern Reservation',
reserved: 'Reserveret',
reservedBy: 'af',
save: 'Gem',
saveWishlist: 'Gem Ønskeliste',
unsaveWishlist: 'Fjern',
share: 'Del',
edit: 'Rediger',
back: 'Tilbage',
noWishes: 'Ingen ønsker endnu',
addFirstWish: 'Tilføj dit første ønske',
emptyWishes: 'Denne ønskeliste har ingen ønsker endnu.',
viewProduct: 'Se Produkt',
claimWishlist: 'Tag Ejerskab Af Ønskeliste',
unclaimWishlist: 'Fjern Ejerskab Af Ønskeliste',
youOwnThis: 'Du Ejer Denne Ønskeliste',
youClaimedThis: 'Du Har Taget Ejerskab Af Denne Ønskeliste',
alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.',
alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.',
claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard',
claimedDescription:
'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
deleteWishlist: 'Slet Ønskeliste',
deleteConfirm:
'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
lockDeletion: 'Lås Sletning',
unlockDeletion: 'Lås Op for Sletning',
shareViewOnly: 'Del med venner (afslører reservationer)',
shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)',
copy: 'Kopiér',
copied: 'Kopieret!',
signInToSave: 'Log ind for at gemme',
saveThisWishlist: 'Gem Denne Ønskeliste',
saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard',
creating: 'Opretter...',
createWishlist: 'Opret Ønskeliste'
},
// Forms
form: {
title: 'Titel',
wishlistTitle: 'Ønskeliste Titel',
wishlistTitlePlaceholder: 'Min Fødselsdagsønskeliste',
description: 'Beskrivelse',
descriptionPlaceholder: 'Tilføj kontekst til din ønskeliset',
descriptionOptional: 'Beskrivelse (valgfri)',
noDescription: 'Ingen beskrivelse',
price: 'Pris',
currency: 'Valuta',
url: 'URL',
link: 'Link (URL)',
image: 'Billede',
imageUrl: 'Billede URL',
submit: 'Indsend',
cancel: 'Annuller',
save: 'Gem',
saveChanges: 'Gem Ændringer',
delete: 'Slet',
email: 'E-mail',
password: 'Adgangskode',
confirmPassword: 'Bekræft Adgangskode',
name: 'Navn',
username: 'Brugernavn',
wishName: 'Ønskenavn',
yourName: 'Dit navn',
optional: 'valgfri',
required: 'påkrævet',
color: 'Farve',
wishlistColor: 'Ønskeliste Farve (valgfri)',
cardColor: 'Kortfarve (valgfri)',
endDate: 'Slutdato',
endDateOptional: 'Slutdato (valgfri)',
position: 'Position i Listen',
addNewWish: 'Tilføj Nyt Ønske'
},
// Forms
form: {
title: 'Titel',
wishlistTitle: 'Ønskeliste Titel',
wishlistTitlePlaceholder: 'Min Fødselsdagsønskeliste',
description: 'Beskrivelse',
descriptionPlaceholder: 'Tilføj kontekst til din ønskeliset',
descriptionOptional: 'Beskrivelse (valgfri)',
noDescription: 'Ingen beskrivelse',
price: 'Pris',
currency: 'Valuta',
url: 'URL',
link: 'Link (URL)',
image: 'Billede',
imageUrl: 'Billede URL',
submit: 'Indsend',
cancel: 'Annuller',
save: 'Gem',
saveChanges: 'Gem Ændringer',
delete: 'Slet',
email: 'E-mail',
password: 'Adgangskode',
confirmPassword: 'Bekræft Adgangskode',
name: 'Navn',
username: 'Brugernavn',
wishName: 'Ønskenavn',
yourName: 'Dit navn',
optional: 'valgfri',
required: 'påkrævet',
color: 'Farve',
wishlistColor: 'Ønskeliste Farve (valgfri)',
cardColor: 'Kortfarve (valgfri)',
endDate: 'Slutdato',
endDateOptional: 'Slutdato (valgfri)',
position: 'Position i Listen',
addNewWish: 'Tilføj Nyt Ønske'
},
// Auth
auth: {
signIn: 'Log Ind',
signUp: 'Tilmeld',
signOut: 'Log Ud',
signingIn: 'Logger ind...',
welcome: 'Velkommen',
welcomeBack: 'Velkommen Tilbage',
signInPrompt: 'Log ind på din konto',
signUpPrompt: 'Tilmeld dig for at administrere dine ønskelister',
createAccount: 'Opret en Konto',
alreadyHaveAccount: 'Har du allerede en konto?',
dontHaveAccount: 'Har du ikke en konto?',
continueWith: 'Eller fortsæt med'
},
// Auth
auth: {
signIn: 'Log Ind',
signUp: 'Tilmeld',
signOut: 'Log Ud',
signingIn: 'Logger ind...',
welcome: 'Velkommen',
welcomeBack: 'Velkommen Tilbage',
signInPrompt: 'Log ind på din konto',
signUpPrompt: 'Tilmeld dig for at administrere dine ønskelister',
createAccount: 'Opret en Konto',
alreadyHaveAccount: 'Har du allerede en konto?',
dontHaveAccount: 'Har du ikke en konto?',
continueWith: 'Eller fortsæt med'
},
// Common
common: {
loading: 'Indlæser...',
error: 'Fejl',
success: 'Succes',
confirm: 'Bekræft',
close: 'Luk',
or: 'eller',
and: 'og'
},
// Common
common: {
loading: 'Indlæser...',
error: 'Fejl',
success: 'Succes',
confirm: 'Bekræft',
close: 'Luk',
or: 'eller',
and: 'og'
},
// Reservation
reservation: {
reserveThis: 'Reservér Denne',
cancelReservation: 'Annuller Reservation',
yourNameOptional: 'Dit navn (valgfri)',
confirm: 'Bekræft',
cancel: 'Annuller'
},
// Reservation
reservation: {
reserveThis: 'Reservér Denne',
cancelReservation: 'Annuller Reservation',
yourNameOptional: 'Dit navn (valgfri)',
confirm: 'Bekræft',
cancel: 'Annuller'
},
// Date formatting
date: {
format: {
short: 'da-DK',
long: 'da-DK'
}
}
// Date formatting
date: {
format: {
short: 'da-DK',
long: 'da-DK'
}
}
};

View File

@@ -1,162 +1,164 @@
export const en = {
// Navigation
nav: {
dashboard: 'Dashboard'
},
// Navigation
nav: {
dashboard: 'Dashboard'
},
// Dashboard
dashboard: {
myWishlists: 'My Wishlists',
myWishlistsDescription: 'Wishlists you own and manage',
claimedWishlists: 'Claimed Wishlists',
claimedWishlistsDescription: 'Wishlists you have claimed and can edit',
savedWishlists: 'Saved Wishlists',
savedWishlistsDescription: "Wishlists you're following",
createNew: '+ Create New',
manage: 'Manage',
copyLink: 'Copy Link',
viewWishlist: 'View Wishlist',
unsave: 'Unsave',
unclaim: 'Unclaim',
delete: 'Delete',
emptyWishlists: "You haven't created any wishlists yet.",
emptyWishlistsAction: 'Create Your First Wishlist',
emptyClaimedWishlists: "You haven't claimed any wishlists yet.",
emptyClaimedWishlistsDescription: "When someone shares an edit link with you, you can claim it to manage it from your dashboard.",
emptySavedWishlists: "You haven't saved any wishlists yet.",
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.",
by: 'by',
ends: 'Ends',
welcomeBack: 'Welcome back',
searchPlaceholder: 'Search wishes...'
},
// Dashboard
dashboard: {
myWishlists: 'My Wishlists',
myWishlistsDescription: 'Wishlists you own and manage',
claimedWishlists: 'Claimed Wishlists',
claimedWishlistsDescription: 'Wishlists you have claimed and can edit',
savedWishlists: 'Saved Wishlists',
savedWishlistsDescription: "Wishlists you're following",
createNew: '+ Create New',
manage: 'Manage',
copyLink: 'Copy Link',
viewWishlist: 'View Wishlist',
unsave: 'Unsave',
unclaim: 'Unclaim',
delete: 'Delete',
emptyWishlists: "You haven't created any wishlists yet.",
emptyWishlistsAction: 'Create Your First Wishlist',
emptyClaimedWishlists: "You haven't claimed any wishlists yet.",
emptyClaimedWishlistsDescription:
'When someone shares an edit link with you, you can claim it to manage it from your dashboard.',
emptySavedWishlists: "You haven't saved any wishlists yet.",
emptySavedWishlistsDescription:
"When viewing someone's wishlist, you can save it to easily find it later.",
by: 'by',
ends: 'Ends',
welcomeBack: 'Welcome back',
searchPlaceholder: 'Search wishes...'
},
// Wishlist
wishlist: {
title: 'Wishlist',
createTitle: 'Create Your Wishlist',
createDescription: 'Create a wishlist and share it with friends and family',
addWish: '+ Add Wish',
editWish: 'Edit Wish',
deleteWish: 'Delete Wish',
reserve: 'Reserve',
unreserve: 'Unreserve',
reserved: 'Reserved',
reservedBy: 'by',
save: 'Save',
saveWishlist: 'Save Wishlist',
unsaveWishlist: 'Unsave',
share: 'Share',
edit: 'Edit',
back: 'Back',
noWishes: 'No wishes yet',
addFirstWish: 'Add your first wish',
emptyWishes: "This wishlist doesn't have any wishes yet.",
viewProduct: 'View Product',
claimWishlist: 'Claim Wishlist',
unclaimWishlist: 'Unclaim Wishlist',
youOwnThis: 'You Own This Wishlist',
youClaimedThis: 'You Have Claimed This Wishlist',
alreadyInDashboard: 'This wishlist is already in your dashboard as the owner.',
alreadyClaimed: 'This wishlist is already in your dashboard as a claimed wishlist.',
claimDescription: 'Claim this wishlist to add it to your dashboard',
claimedDescription: 'You have claimed this wishlist and can access it from your dashboard',
deleteWishlist: 'Delete Wishlist',
deleteConfirm: 'Are you sure you want to delete this wishlist? This action cannot be undone.',
lockDeletion: 'Lock Deletion',
unlockDeletion: 'Unlock for Deletion',
shareViewOnly: 'Share with friends (view only)',
shareEditLink: 'Your edit link (keep this private!)',
copy: 'Copy',
copied: 'Copied!',
signInToSave: 'Sign in to Save',
saveThisWishlist: 'Save This Wishlist',
saveDescription: 'Save this wishlist to easily find it later in your dashboard',
creating: 'Creating...',
createWishlist: 'Create Wishlist'
},
// Wishlist
wishlist: {
title: 'Wishlist',
createTitle: 'Create Your Wishlist',
createDescription: 'Create a wishlist and share it with friends and family',
addWish: '+ Add Wish',
editWish: 'Edit Wish',
deleteWish: 'Delete Wish',
reserve: 'Reserve',
unreserve: 'Unreserve',
reserved: 'Reserved',
reservedBy: 'by',
save: 'Save',
saveWishlist: 'Save Wishlist',
unsaveWishlist: 'Unsave',
share: 'Share',
edit: 'Edit',
back: 'Back',
noWishes: 'No wishes yet',
addFirstWish: 'Add your first wish',
emptyWishes: "This wishlist doesn't have any wishes yet.",
viewProduct: 'View Product',
claimWishlist: 'Claim Wishlist',
unclaimWishlist: 'Unclaim Wishlist',
youOwnThis: 'You Own This Wishlist',
youClaimedThis: 'You Have Claimed This Wishlist',
alreadyInDashboard: 'This wishlist is already in your dashboard as the owner.',
alreadyClaimed: 'This wishlist is already in your dashboard as a claimed wishlist.',
claimDescription: 'Claim this wishlist to add it to your dashboard',
claimedDescription: 'You have claimed this wishlist and can access it from your dashboard',
deleteWishlist: 'Delete Wishlist',
deleteConfirm: 'Are you sure you want to delete this wishlist? This action cannot be undone.',
lockDeletion: 'Lock Deletion',
unlockDeletion: 'Unlock for Deletion',
shareViewOnly: 'Share with friends (view only)',
shareEditLink: 'Your edit link (keep this private!)',
copy: 'Copy',
copied: 'Copied!',
signInToSave: 'Sign in to Save',
saveThisWishlist: 'Save This Wishlist',
saveDescription: 'Save this wishlist to easily find it later in your dashboard',
creating: 'Creating...',
createWishlist: 'Create Wishlist'
},
// Forms
form: {
title: 'Title',
wishlistTitle: 'Wishlist Title',
wishlistTitlePlaceholder: 'My Birthday Wishlist',
description: 'Description',
descriptionPlaceholder: 'Add some context for your wishlist...',
descriptionOptional: 'Description (optional)',
noDescription: 'No description',
price: 'Price',
currency: 'Currency',
url: 'URL',
link: 'Link (URL)',
image: 'Image',
imageUrl: 'Image URL',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save',
saveChanges: 'Save Changes',
delete: 'Delete',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
name: 'Name',
username: 'Username',
wishName: 'Wish Name',
yourName: 'Your name',
optional: 'optional',
required: 'required',
color: 'Color',
wishlistColor: 'Wishlist Color (optional)',
cardColor: 'Card Color (optional)',
endDate: 'End Date',
endDateOptional: 'End Date (optional)',
position: 'Position in List',
addNewWish: 'Add New Wish'
},
// Forms
form: {
title: 'Title',
wishlistTitle: 'Wishlist Title',
wishlistTitlePlaceholder: 'My Birthday Wishlist',
description: 'Description',
descriptionPlaceholder: 'Add some context for your wishlist...',
descriptionOptional: 'Description (optional)',
noDescription: 'No description',
price: 'Price',
currency: 'Currency',
url: 'URL',
link: 'Link (URL)',
image: 'Image',
imageUrl: 'Image URL',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save',
saveChanges: 'Save Changes',
delete: 'Delete',
email: 'Email',
password: 'Password',
confirmPassword: 'Confirm Password',
name: 'Name',
username: 'Username',
wishName: 'Wish Name',
yourName: 'Your name',
optional: 'optional',
required: 'required',
color: 'Color',
wishlistColor: 'Wishlist Color (optional)',
cardColor: 'Card Color (optional)',
endDate: 'End Date',
endDateOptional: 'End Date (optional)',
position: 'Position in List',
addNewWish: 'Add New Wish'
},
// Auth
auth: {
signIn: 'Sign In',
signUp: 'Sign Up',
signOut: 'Sign Out',
signingIn: 'Signing in...',
welcome: 'Welcome',
welcomeBack: 'Welcome Back',
signInPrompt: 'Sign in to your account',
signUpPrompt: 'Sign up to manage your wishlists',
createAccount: 'Create an Account',
alreadyHaveAccount: 'Already have an account?',
dontHaveAccount: "Don't have an account?",
continueWith: 'Or continue with'
},
// Auth
auth: {
signIn: 'Sign In',
signUp: 'Sign Up',
signOut: 'Sign Out',
signingIn: 'Signing in...',
welcome: 'Welcome',
welcomeBack: 'Welcome Back',
signInPrompt: 'Sign in to your account',
signUpPrompt: 'Sign up to manage your wishlists',
createAccount: 'Create an Account',
alreadyHaveAccount: 'Already have an account?',
dontHaveAccount: "Don't have an account?",
continueWith: 'Or continue with'
},
// Common
common: {
loading: 'Loading...',
error: 'Error',
success: 'Success',
confirm: 'Confirm',
close: 'Close',
or: 'or',
and: 'and'
},
// Common
common: {
loading: 'Loading...',
error: 'Error',
success: 'Success',
confirm: 'Confirm',
close: 'Close',
or: 'or',
and: 'and'
},
// Reservation
reservation: {
reserveThis: 'Reserve This',
cancelReservation: 'Cancel Reservation',
yourNameOptional: 'Your name (optional)',
confirm: 'Confirm',
cancel: 'Cancel'
},
// Reservation
reservation: {
reserveThis: 'Reserve This',
cancelReservation: 'Cancel Reservation',
yourNameOptional: 'Your name (optional)',
confirm: 'Confirm',
cancel: 'Cancel'
},
// Date formatting
date: {
format: {
short: 'en-US',
long: 'en-US'
}
}
// Date formatting
date: {
format: {
short: 'en-US',
long: 'en-US'
}
}
};
export type Translation = typeof en;

View File

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

View File

@@ -1 +0,0 @@

View File

@@ -1,86 +1,92 @@
export function sanitizeString(input: string | null | undefined, maxLength: number = 1000): string | null {
if (input === null || input === undefined) {
return null;
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
throw new Error(`Input exceeds maximum length of ${maxLength}`);
}
return trimmed;
export function sanitizeString(
input: string | null | undefined,
maxLength: number = 1000
): string | null {
if (input === null || input === undefined) {
return null;
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
throw new Error(`Input exceeds maximum length of ${maxLength}`);
}
return trimmed;
}
export function sanitizeUrl(url: string | null | undefined): string | null {
if (!url) {
return null;
}
return url.trim();
if (!url) {
return null;
}
return url.trim();
}
export function sanitizeText(input: string | null | undefined, maxLength: number = 10000): string | null {
if (input === null || input === undefined) {
return null;
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
throw new Error(`Text exceeds maximum length of ${maxLength}`);
}
return trimmed;
export function sanitizeText(
input: string | null | undefined,
maxLength: number = 10000
): string | null {
if (input === null || input === undefined) {
return null;
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
throw new Error(`Text exceeds maximum length of ${maxLength}`);
}
return trimmed;
}
export function sanitizePrice(price: string | null | undefined): number | null {
if (!price) {
return null;
}
const parsed = parseFloat(price.trim());
if (isNaN(parsed)) {
throw new Error('Invalid price format');
}
if (parsed < 0) {
throw new Error('Price cannot be negative');
}
return parsed;
if (!price) {
return null;
}
const parsed = parseFloat(price.trim());
if (isNaN(parsed)) {
throw new Error('Invalid price format');
}
if (parsed < 0) {
throw new Error('Price cannot be negative');
}
return parsed;
}
export function sanitizeColor(color: string | null | undefined): string | null {
if (!color) {
return null;
}
return color.trim();
if (!color) {
return null;
}
return color.trim();
}
export function sanitizeCurrency(currency: string | null | undefined): string {
if (!currency) {
return 'DKK';
}
return currency.trim().toUpperCase();
if (!currency) {
return 'DKK';
}
return currency.trim().toUpperCase();
}
export function sanitizeUsername(username: string): string {
const trimmed = username.trim().toLowerCase();
if (trimmed.length < 3 || trimmed.length > 50) {
throw new Error('Username must be between 3 and 50 characters');
}
return trimmed;
const trimmed = username.trim().toLowerCase();
if (trimmed.length < 3 || trimmed.length > 50) {
throw new Error('Username must be between 3 and 50 characters');
}
return trimmed;
}
export function sanitizeId(id: string | null | undefined): string {
if (!id) {
throw new Error('ID is required');
}
const trimmed = id.trim();
if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid ID');
}
return trimmed;
if (!id) {
throw new Error('ID is required');
}
const trimmed = id.trim();
if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid ID');
}
return trimmed;
}
export function sanitizeToken(token: string | null | undefined): string {
if (!token) {
throw new Error('Token is required');
}
const trimmed = token.trim();
if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid token');
}
return trimmed;
}
if (!token) {
throw new Error('Token is required');
}
const trimmed = token.trim();
if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid token');
}
return trimmed;
}

View File

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

View File

@@ -4,66 +4,67 @@ type Theme = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
class ThemeStore {
current = $state<Theme>('system');
resolved = $state<ResolvedTheme>('light');
current = $state<Theme>('system');
resolved = $state<ResolvedTheme>('light');
constructor() {
if (browser) {
const stored = localStorage.getItem('theme') as Theme | null;
this.current = stored || 'system';
this.applyTheme();
constructor() {
if (browser) {
const stored = localStorage.getItem('theme') as Theme | null;
this.current = stored || 'system';
this.applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (this.current === 'system') {
this.applyTheme();
}
});
}
}
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (this.current === 'system') {
this.applyTheme();
}
});
}
}
private applyTheme() {
if (!browser) return;
private applyTheme() {
if (!browser) return;
const isDark = this.current === 'dark' ||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const isDark =
this.current === 'dark' ||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
this.resolved = isDark ? 'dark' : 'light';
this.resolved = isDark ? 'dark' : 'light';
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
getResolvedTheme(): ResolvedTheme {
return this.resolved;
}
getResolvedTheme(): ResolvedTheme {
return this.resolved;
}
toggle() {
// Cycle through: light -> dark -> system -> light
if (this.current === 'light') {
this.current = 'dark';
} else if (this.current === 'dark') {
this.current = 'system';
} else {
this.current = 'light';
}
toggle() {
// Cycle through: light -> dark -> system -> light
if (this.current === 'light') {
this.current = 'dark';
} else if (this.current === 'dark') {
this.current = 'system';
} else {
this.current = 'light';
}
if (browser) {
localStorage.setItem('theme', this.current);
this.applyTheme();
}
}
if (browser) {
localStorage.setItem('theme', this.current);
this.applyTheme();
}
}
set(theme: Theme) {
this.current = theme;
if (browser) {
localStorage.setItem('theme', this.current);
}
this.applyTheme();
}
set(theme: Theme) {
this.current = theme;
if (browser) {
localStorage.setItem('theme', this.current);
}
this.applyTheme();
}
}
export const themeStore = new ThemeStore();

View File

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

View File

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

View File

@@ -5,98 +5,96 @@
const LOCAL_WISHLISTS_KEY = 'local_wishlists';
export interface LocalWishlist {
ownerToken: string;
publicToken: string;
title: string;
createdAt: string;
isFavorite?: boolean;
ownerToken: string;
publicToken: string;
title: string;
createdAt: string;
isFavorite?: boolean;
}
/**
* Get all local wishlists from localStorage
*/
export function getLocalWishlists(): LocalWishlist[] {
if (typeof window === 'undefined') return [];
if (typeof window === 'undefined') return [];
try {
const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to parse local wishlists:', error);
return [];
}
try {
const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to parse local wishlists:', error);
return [];
}
}
/**
* Add a wishlist to localStorage
*/
export function addLocalWishlist(wishlist: LocalWishlist): void {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined') return;
try {
const wishlists = getLocalWishlists();
try {
const wishlists = getLocalWishlists();
const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken);
if (exists) return;
const exists = wishlists.some((w) => w.ownerToken === wishlist.ownerToken);
if (exists) return;
wishlists.push(wishlist);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists));
} catch (error) {
console.error('Failed to add local wishlist:', error);
}
wishlists.push(wishlist);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists));
} catch (error) {
console.error('Failed to add local wishlist:', error);
}
}
/**
* Remove a wishlist from localStorage (forget it)
*/
export function forgetLocalWishlist(ownerToken: string): void {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined') return;
try {
const wishlists = getLocalWishlists();
const filtered = wishlists.filter(w => w.ownerToken !== ownerToken);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to forget local wishlist:', error);
}
try {
const wishlists = getLocalWishlists();
const filtered = wishlists.filter((w) => w.ownerToken !== ownerToken);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered));
} catch (error) {
console.error('Failed to forget local wishlist:', error);
}
}
/**
* Clear all local wishlists (e.g., when user claims all wishlists)
*/
export function clearLocalWishlists(): void {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined') return;
try {
localStorage.removeItem(LOCAL_WISHLISTS_KEY);
} catch (error) {
console.error('Failed to clear local wishlists:', error);
}
try {
localStorage.removeItem(LOCAL_WISHLISTS_KEY);
} catch (error) {
console.error('Failed to clear local wishlists:', error);
}
}
/**
* Check if a wishlist is in local storage
*/
export function isLocalWishlist(ownerToken: string): boolean {
const wishlists = getLocalWishlists();
return wishlists.some(w => w.ownerToken === ownerToken);
const wishlists = getLocalWishlists();
return wishlists.some((w) => w.ownerToken === ownerToken);
}
/**
* Toggle favorite status for a local wishlist
*/
export function toggleLocalFavorite(ownerToken: string): void {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined') return;
try {
const wishlists = getLocalWishlists();
const updated = wishlists.map(w =>
w.ownerToken === ownerToken
? { ...w, isFavorite: !w.isFavorite }
: w
);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
} catch (error) {
console.error('Failed to toggle local wishlist favorite:', error);
}
try {
const wishlists = getLocalWishlists();
const updated = wishlists.map((w) =>
w.ownerToken === ownerToken ? { ...w, isFavorite: !w.isFavorite } : w
);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
} catch (error) {
console.error('Failed to toggle local wishlist favorite:', error);
}
}

View File

@@ -3,31 +3,31 @@ import { themeStore } from '$lib/stores/theme.svelte';
export type ThemePattern = 'snow' | 'none';
export interface Theme {
name: string;
pattern: ThemePattern;
name: string;
pattern: ThemePattern;
}
export const AVAILABLE_THEMES: Record<string, Theme> = {
none: {
name: 'None',
pattern: 'none'
},
waves: {
name: 'Snow',
pattern: 'snow'
}
none: {
name: 'None',
pattern: 'none'
},
waves: {
name: 'Snow',
pattern: 'snow'
}
};
export const DEFAULT_THEME = 'none';
export const PATTERN_OPACITY = 0.1;
export function getTheme(themeName?: string | null): Theme {
if (!themeName || !AVAILABLE_THEMES[themeName]) {
return AVAILABLE_THEMES[DEFAULT_THEME];
}
return AVAILABLE_THEMES[themeName];
if (!themeName || !AVAILABLE_THEMES[themeName]) {
return AVAILABLE_THEMES[DEFAULT_THEME];
}
return AVAILABLE_THEMES[themeName];
}
export function getPatternColor(customColor?: string): string {
return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000');
return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000');
}

View File

@@ -3,59 +3,59 @@
*/
type UpdateField = {
[key: string]: string;
[key: string]: string;
};
async function updateWishlist(field: UpdateField): Promise<boolean> {
const response = await fetch("?/updateWishlist", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(field),
});
const response = await fetch('?/updateWishlist', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(field)
});
if (!response.ok) {
console.error(`Failed to update wishlist: ${Object.keys(field)[0]}`);
return false;
}
return true;
if (!response.ok) {
console.error(`Failed to update wishlist: ${Object.keys(field)[0]}`);
return false;
}
return true;
}
export async function updateTitle(title: string): Promise<boolean> {
return updateWishlist({ title });
return updateWishlist({ title });
}
export async function updateDescription(description: string | null): Promise<boolean> {
return updateWishlist({ description: description || "" });
return updateWishlist({ description: description || '' });
}
export async function updateColor(color: string | null): Promise<boolean> {
return updateWishlist({ color: color || "" });
return updateWishlist({ color: color || '' });
}
export async function updateEndDate(endDate: string | null): Promise<boolean> {
return updateWishlist({ endDate: endDate || "" });
return updateWishlist({ endDate: endDate || '' });
}
export async function updateTheme(theme: string | null): Promise<boolean> {
return updateWishlist({ theme: theme || "none" });
return updateWishlist({ theme: theme || 'none' });
}
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
const response = await fetch("?/reorderItems", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
items: JSON.stringify(items),
}),
});
const response = await fetch('?/reorderItems', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
items: JSON.stringify(items)
})
});
if (!response.ok) {
console.error("Failed to update item order");
return false;
}
return true;
if (!response.ok) {
console.error('Failed to update item order');
return false;
}
return true;
}

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import '../app.css';
let { children } = $props();
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
<link rel="icon" href={favicon} />
</svelte:head>
<div class="min-h-screen bg-slate-50 dark:bg-slate-950">
{@render children()}
{@render children()}
</div>

View File

@@ -1,8 +1,8 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
return {
session
};
const session = await event.locals.auth();
return {
session
};
};

View File

@@ -1,111 +1,123 @@
<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 { LanguageToggle } from '$lib/components/ui/language-toggle';
import { goto } from '$app/navigation';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import type { PageData } from './$types';
import { languageStore } from '$lib/stores/language.svelte';
import { addLocalWishlist } from '$lib/utils/localWishlists';
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 { LanguageToggle } from '$lib/components/ui/language-toggle';
import { goto } from '$app/navigation';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import type { PageData } from './$types';
import { languageStore } from '$lib/stores/language.svelte';
import { addLocalWishlist } from '$lib/utils/localWishlists';
let { data }: { data: PageData } = $props();
let { data }: { data: PageData } = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
let title = $state('');
let description = $state('');
let color = $state<string | null>(null);
let isCreating = $state(false);
let title = $state('');
let description = $state('');
let color = $state<string | null>(null);
let isCreating = $state(false);
async function createWishlist() {
if (!title.trim()) return;
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 })
});
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 result = await response.json();
const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result;
if (response.ok) {
const result = await response.json();
const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result;
// If user is not authenticated, save to localStorage
if (!data.session?.user) {
addLocalWishlist({
ownerToken,
publicToken,
title: wishlistTitle,
createdAt
});
}
// If user is not authenticated, save to localStorage
if (!data.session?.user) {
addLocalWishlist({
ownerToken,
publicToken,
title: wishlistTitle,
createdAt
});
}
goto(`/wishlist/${ownerToken}/edit`);
}
} catch (error) {
console.error('Failed to create wishlist:', error);
} finally {
isCreating = false;
}
}
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 flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0">
<CardTitle class="text-3xl">{t.wishlist.createTitle}</CardTitle>
<CardDescription>
{t.wishlist.createDescription}
</CardDescription>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<LanguageToggle />
<ThemeToggle />
<Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button>
{#if !data.session?.user}
<Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent>
<form onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4">
<div class="space-y-2">
<Label for="title">{t.form.wishlistTitle}</Label>
<Input
id="title"
bind:value={title}
placeholder={t.form.wishlistTitlePlaceholder}
required
/>
</div>
<div class="space-y-2">
<Label for="description">{t.form.descriptionOptional}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={t.form.descriptionPlaceholder}
rows={3}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="color">{t.form.wishlistColor}</Label>
<ColorPicker bind:color={color} size="sm" />
</div>
</div>
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
</Button>
</form>
</CardContent>
</Card>
<Card class="w-full max-w-lg">
<CardHeader>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0">
<CardTitle class="text-3xl">{t.wishlist.createTitle}</CardTitle>
<CardDescription>
{t.wishlist.createDescription}
</CardDescription>
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<LanguageToggle />
<ThemeToggle />
<Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button>
{#if !data.session?.user}
<Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button>
{/if}
</div>
</div>
</CardHeader>
<CardContent>
<form
onsubmit={(e) => {
e.preventDefault();
createWishlist();
}}
class="space-y-4"
>
<div class="space-y-2">
<Label for="title">{t.form.wishlistTitle}</Label>
<Input
id="title"
bind:value={title}
placeholder={t.form.wishlistTitlePlaceholder}
required
/>
</div>
<div class="space-y-2">
<Label for="description">{t.form.descriptionOptional}</Label>
<Textarea
id="description"
bind:value={description}
placeholder={t.form.descriptionPlaceholder}
rows={3}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="color">{t.form.wishlistColor}</Label>
<ColorPicker bind:color size="sm" />
</div>
</div>
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
</Button>
</form>
</CardContent>
</Card>
</div>

View File

@@ -1,64 +1,64 @@
import type { RequestHandler } from './$types';
function isValidImageUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false;
}
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
return true;
} catch {
return false;
}
try {
const parsedUrl = new URL(url);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false;
}
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
return true;
} catch {
return false;
}
}
export const GET: RequestHandler = async ({ url }) => {
const imageUrl = url.searchParams.get('url');
const imageUrl = url.searchParams.get('url');
if (!imageUrl) {
return new Response('Image URL is required', { status: 400 });
}
if (!imageUrl) {
return new Response('Image URL is required', { status: 400 });
}
if (!isValidImageUrl(imageUrl)) {
return new Response('Invalid image URL', { status: 400 });
}
if (!isValidImageUrl(imageUrl)) {
return new Response('Invalid image URL', { status: 400 });
}
try {
// Fetch the image with proper headers to avoid blocking
const response = await fetch(imageUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Referer': new URL(imageUrl).origin,
'Sec-Fetch-Dest': 'image',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site'
}
});
try {
// Fetch the image with proper headers to avoid blocking
const response = await fetch(imageUrl, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
Referer: new URL(imageUrl).origin,
'Sec-Fetch-Dest': 'image',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site'
}
});
if (!response.ok) {
return new Response('Failed to fetch image', { status: response.status });
}
if (!response.ok) {
return new Response('Failed to fetch image', { status: response.status });
}
const contentType = response.headers.get('content-type') || 'image/jpeg';
const imageBuffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || 'image/jpeg';
const imageBuffer = await response.arrayBuffer();
// Return the image with appropriate headers
return new Response(imageBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
console.error('Image proxy error:', error);
return new Response('Failed to proxy image', { status: 500 });
}
// Return the image with appropriate headers
return new Response(imageBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
'Access-Control-Allow-Origin': '*'
}
});
} catch (error) {
console.error('Image proxy error:', error);
return new Response('Failed to proxy image', { status: 500 });
}
};

View File

@@ -2,220 +2,241 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
function isValidUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) {
return false;
}
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
return true;
} catch {
return false;
}
try {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) {
return false;
}
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
return true;
} catch {
return false;
}
}
export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json();
const { url } = await request.json();
if (!url) {
return json({ error: 'URL is required' }, { status: 400 });
}
if (!url) {
return json({ error: 'URL is required' }, { status: 400 });
}
if (!isValidUrl(url)) {
return json({ error: 'Invalid URL' }, { status: 400 });
}
if (!isValidUrl(url)) {
return json({ error: 'Invalid URL' }, { status: 400 });
}
try {
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
try {
const response = await fetch(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
}
});
if (!response.ok) {
return json({ error: 'Failed to fetch URL' }, { status: 400 });
}
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 html = await response.text();
const baseUrl = new URL(url);
const origin = baseUrl.origin;
const imageUrls: string[] = [];
const imageUrls: string[] = [];
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}`;
}
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}`;
}
function isLikelyProductImage(url: string): boolean {
const lower = url.toLowerCase();
const badPatterns = [
'logo', 'icon', 'sprite', 'favicon', 'banner', 'footer',
'header', 'background', 'pattern', 'placeholder', 'thumbnail-small',
'btn', 'button', 'menu', 'nav', 'navigation', 'social',
'instagram', 'facebook', 'twitter', 'linkedin', 'pinterest'
];
if (badPatterns.some(pattern => lower.includes(pattern))) {
return false;
}
if (url.endsWith('.svg')) {
return false;
}
if (lower.includes('data:image')) {
return false;
}
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
return false;
}
return true;
}
function isLikelyProductImage(url: string): boolean {
const lower = url.toLowerCase();
const badPatterns = [
'logo',
'icon',
'sprite',
'favicon',
'banner',
'footer',
'header',
'background',
'pattern',
'placeholder',
'thumbnail-small',
'btn',
'button',
'menu',
'nav',
'navigation',
'social',
'instagram',
'facebook',
'twitter',
'linkedin',
'pinterest'
];
if (badPatterns.some((pattern) => lower.includes(pattern))) {
return false;
}
if (url.endsWith('.svg')) {
return false;
}
if (lower.includes('data:image')) {
return false;
}
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
return false;
}
return true;
}
let match;
let match;
// Priority 1: OpenGraph and Twitter meta tags (main product image)
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
const twitterImageRegex = /<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
// Priority 1: OpenGraph and Twitter meta tags (main product image)
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
const twitterImageRegex =
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
while ((match = ogImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
while ((match = ogImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
while ((match = twitterImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
while ((match = twitterImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 2: Look for JSON-LD structured data (very common in modern e-commerce)
const jsonLdRegex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
while ((match = jsonLdRegex.exec(html)) !== null) {
try {
const jsonStr = match[1];
const jsonData = JSON.parse(jsonStr);
// Priority 2: Look for JSON-LD structured data (very common in modern e-commerce)
const jsonLdRegex =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
while ((match = jsonLdRegex.exec(html)) !== null) {
try {
const jsonStr = match[1];
const jsonData = JSON.parse(jsonStr);
function extractImages(obj: any, results: Set<string>) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
obj.forEach((item: any) => extractImages(item, results));
} else {
for (const key in obj) {
if (key === 'image' || key === 'thumbnail' || key === 'url') {
const val = obj[key];
if (typeof val === 'string') {
const url = toAbsoluteUrl(val);
if (isLikelyProductImage(url)) {
results.add(url);
}
}
if (Array.isArray(val)) {
val.forEach((item: any) => {
if (typeof item === 'string') {
const url = toAbsoluteUrl(item);
if (isLikelyProductImage(url)) {
results.add(url);
}
}
});
}
} else if (typeof obj[key] === 'object') {
extractImages(obj[key], results);
}
}
}
}
function extractImages(obj: any, results: Set<string>) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
obj.forEach((item: any) => extractImages(item, results));
} else {
for (const key in obj) {
if (key === 'image' || key === 'thumbnail' || key === 'url') {
const val = obj[key];
if (typeof val === 'string') {
const url = toAbsoluteUrl(val);
if (isLikelyProductImage(url)) {
results.add(url);
}
}
if (Array.isArray(val)) {
val.forEach((item: any) => {
if (typeof item === 'string') {
const url = toAbsoluteUrl(item);
if (isLikelyProductImage(url)) {
results.add(url);
}
}
});
}
} else if (typeof obj[key] === 'object') {
extractImages(obj[key], results);
}
}
}
}
const jsonImages = new Set<string>();
extractImages(jsonData, jsonImages);
jsonImages.forEach(img => {
if (!imageUrls.includes(img)) {
imageUrls.push(img);
}
});
} catch {
// JSON parsing failed, continue
}
}
const jsonImages = new Set<string>();
extractImages(jsonData, jsonImages);
jsonImages.forEach((img) => {
if (!imageUrls.includes(img)) {
imageUrls.push(img);
}
});
} catch {
// JSON parsing failed, continue
}
}
// Priority 3: Look for data-image attributes (common in React/SPA)
const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
while ((match = dataImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 3: Look for data-image attributes (common in React/SPA)
const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
while ((match = dataImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 4: srcset attributes (responsive images)
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
while ((match = srcsetRegex.exec(html)) !== null) {
const srcsetValue = match[1];
const srcsetUrls = srcsetValue.split(',').map((s) => {
const parts = s.trim().split(/\s+/);
return parts[0];
});
for (const srcsetUrl of srcsetUrls) {
const url = toAbsoluteUrl(srcsetUrl);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
}
// Priority 4: srcset attributes (responsive images)
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
while ((match = srcsetRegex.exec(html)) !== null) {
const srcsetValue = match[1];
const srcsetUrls = srcsetValue.split(',').map((s) => {
const parts = s.trim().split(/\s+/);
return parts[0];
});
for (const srcsetUrl of srcsetUrls) {
const url = toAbsoluteUrl(srcsetUrl);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
}
// Priority 5: data-src attributes (lazy loaded)
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
while ((match = dataSrcRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 5: data-src attributes (lazy loaded)
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
while ((match = dataSrcRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 6: Regular img src attributes
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
while ((match = imgRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 6: Regular img src attributes
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
while ((match = imgRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 7: Background images in style attributes (common in some e-commerce)
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi;
while ((match = bgImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) {
imageUrls.push(url);
}
}
// Priority 7: Background images in style attributes (common in some e-commerce)
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi;
while ((match = bgImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) {
imageUrls.push(url);
}
}
// Final filtering: remove very long URLs and duplicates
const finalImages = [...new Set(imageUrls)].filter(url => {
return url.length < 2000 && isLikelyProductImage(url);
});
// Final filtering: remove very long URLs and duplicates
const finalImages = [...new Set(imageUrls)].filter((url) => {
return url.length < 2000 && isLikelyProductImage(url);
});
return json({ images: finalImages.slice(0, 30) });
} catch (error) {
return json({ error: 'Failed to scrape images' }, { status: 500 });
}
return json({ images: finalImages.slice(0, 30) });
} catch (error) {
return json({ error: 'Failed to scrape images' }, { status: 500 });
}
};

View File

@@ -5,34 +5,31 @@ import { wishlists } from '$lib/db/schema';
import { eq, or } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params }) => {
const { token } = params;
const { token } = params;
// Find wishlist by either ownerToken or publicToken
const wishlist = await db.query.wishlists.findFirst({
where: or(
eq(wishlists.ownerToken, token),
eq(wishlists.publicToken, token)
),
with: {
items: {
orderBy: (items, { asc }) => [asc(items.order)]
}
}
});
// Find wishlist by either ownerToken or publicToken
const wishlist = await db.query.wishlists.findFirst({
where: or(eq(wishlists.ownerToken, token), eq(wishlists.publicToken, token)),
with: {
items: {
orderBy: (items, { asc }) => [asc(items.order)]
}
}
});
if (!wishlist) {
return json({ error: 'Wishlist not found' }, { status: 404 });
}
if (!wishlist) {
return json({ error: 'Wishlist not found' }, { status: 404 });
}
// Return only the necessary fields
return json({
id: wishlist.id,
title: wishlist.title,
ownerToken: wishlist.ownerToken,
publicToken: wishlist.publicToken,
createdAt: wishlist.createdAt,
theme: wishlist.theme,
color: wishlist.color,
items: wishlist.items || []
});
// Return only the necessary fields
return json({
id: wishlist.id,
title: wishlist.title,
ownerToken: wishlist.ownerToken,
publicToken: wishlist.publicToken,
createdAt: wishlist.createdAt,
theme: wishlist.theme,
color: wishlist.color,
items: wishlist.items || []
});
};

View File

@@ -6,47 +6,47 @@ import { createId } from '@paralleldrive/cuid2';
import { sanitizeString, sanitizeColor } from '$lib/server/validation';
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json();
const body = await request.json();
let title: string | null;
let description: string | null;
let color: string | null;
let title: string | null;
let description: string | null;
let color: string | null;
try {
title = sanitizeString(body.title, 200);
description = sanitizeString(body.description, 2000);
color = sanitizeColor(body.color);
} catch (error) {
return json({ error: 'Invalid input' }, { status: 400 });
}
try {
title = sanitizeString(body.title, 200);
description = sanitizeString(body.description, 2000);
color = sanitizeColor(body.color);
} catch (error) {
return json({ error: 'Invalid input' }, { status: 400 });
}
if (!title) {
return json({ error: 'Title is required' }, { status: 400 });
}
if (!title) {
return json({ error: 'Title is required' }, { status: 400 });
}
const session = await locals.auth();
const userId = session?.user?.id || null;
const session = await locals.auth();
const userId = session?.user?.id || null;
const ownerToken = createId();
const publicToken = createId();
const ownerToken = createId();
const publicToken = createId();
const [wishlist] = await db
.insert(wishlists)
.values({
title,
description,
color,
ownerToken,
publicToken,
userId
})
.returning();
const [wishlist] = await db
.insert(wishlists)
.values({
title,
description,
color,
ownerToken,
publicToken,
userId
})
.returning();
return json({
ownerToken,
publicToken,
id: wishlist.id,
title: wishlist.title,
createdAt: wishlist.createdAt
});
return json({
ownerToken,
publicToken,
id: wishlist.id,
title: wishlist.title,
createdAt: wishlist.createdAt
});
};

View File

@@ -5,190 +5,194 @@ import { wishlists, savedWishlists, users } from '$lib/db/schema';
import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const session = await event.locals.auth();
// Allow anonymous users to access dashboard for local wishlists
if (!session?.user?.id) {
return {
user: null,
wishlists: [],
savedWishlists: [],
isAuthenticated: false
};
}
// Allow anonymous users to access dashboard for local wishlists
if (!session?.user?.id) {
return {
user: null,
wishlists: [],
savedWishlists: [],
isAuthenticated: false
};
}
// Fetch user with theme
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id)
});
// Fetch user with theme
const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id)
});
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 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)]
});
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)]
});
// Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist)
// This ensures users only see ownerToken if they claimed via edit link
const savedWithAccess = saved.map(s => ({
...s,
wishlist: s.wishlist ? {
...s.wishlist,
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
ownerToken: s.ownerToken,
// Keep publicToken as-is for viewing
publicToken: s.wishlist.publicToken
} : null
}));
// Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist)
// This ensures users only see ownerToken if they claimed via edit link
const savedWithAccess = saved.map((s) => ({
...s,
wishlist: s.wishlist
? {
...s.wishlist,
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
ownerToken: s.ownerToken,
// Keep publicToken as-is for viewing
publicToken: s.wishlist.publicToken
}
: null
}));
return {
user: user,
wishlists: userWishlists,
savedWishlists: savedWithAccess,
isAuthenticated: true
};
return {
user: user,
wishlists: userWishlists,
savedWishlists: savedWithAccess,
isAuthenticated: true
};
};
export const actions: Actions = {
toggleFavorite: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
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';
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' };
}
if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' };
}
await db.update(wishlists)
.set({ isFavorite: !isFavorite, updatedAt: new Date() })
.where(eq(wishlists.id, wishlistId));
await db
.update(wishlists)
.set({ isFavorite: !isFavorite, updatedAt: new Date() })
.where(eq(wishlists.id, wishlistId));
return { success: true };
},
return { success: true };
},
toggleSavedFavorite: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
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';
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' };
}
if (!savedWishlistId) {
return { success: false, error: 'Saved wishlist ID is required' };
}
await db.update(savedWishlists)
.set({ isFavorite: !isFavorite })
.where(eq(savedWishlists.id, savedWishlistId));
await db
.update(savedWishlists)
.set({ isFavorite: !isFavorite })
.where(eq(savedWishlists.id, savedWishlistId));
return { success: true };
},
return { success: true };
},
unsaveWishlist: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
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;
const formData = await request.formData();
const savedWishlistId = formData.get('savedWishlistId') as string;
if (!savedWishlistId) {
return { success: false, error: 'Saved wishlist ID is required' };
}
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)
));
await db
.delete(savedWishlists)
.where(
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id))
);
return { success: true };
},
return { success: true };
},
deleteWishlist: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
deleteWishlist: 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 formData = await request.formData();
const wishlistId = formData.get('wishlistId') as string;
if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' };
}
if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' };
}
await db.delete(wishlists)
.where(and(
eq(wishlists.id, wishlistId),
eq(wishlists.userId, session.user.id)
));
await db
.delete(wishlists)
.where(and(eq(wishlists.id, wishlistId), eq(wishlists.userId, session.user.id)));
return { success: true };
},
return { success: true };
},
updateDashboardTheme: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
updateDashboardTheme: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
const formData = await request.formData();
const theme = formData.get('theme') as string;
const formData = await request.formData();
const theme = formData.get('theme') as string;
if (!theme) {
return { success: false, error: 'Theme is required' };
}
if (!theme) {
return { success: false, error: 'Theme is required' };
}
await db.update(users)
.set({ dashboardTheme: theme, updatedAt: new Date() })
.where(eq(users.id, session.user.id));
await db
.update(users)
.set({ dashboardTheme: theme, updatedAt: new Date() })
.where(eq(users.id, session.user.id));
return { success: true };
},
return { success: true };
},
updateDashboardColor: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
updateDashboardColor: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw redirect(303, '/signin');
}
const formData = await request.formData();
const color = formData.get('color') as string | null;
const formData = await request.formData();
const color = formData.get('color') as string | null;
await db.update(users)
.set({ dashboardColor: color, updatedAt: new Date() })
.where(eq(users.id, session.user.id));
await db
.update(users)
.set({ dashboardColor: color, updatedAt: new Date() })
.where(eq(users.id, session.user.id));
return { success: true };
}
return { success: true };
}
};

View File

@@ -1,255 +1,279 @@
<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 WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte';
import { enhance } from '$app/forms';
import { Star } from '@lucide/svelte';
import { languageStore } from '$lib/stores/language.svelte';
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 WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte';
import { enhance } from '$app/forms';
import { Star } from '@lucide/svelte';
import { languageStore } from '$lib/stores/language.svelte';
let { data }: { data: PageData } = $props();
let { data }: { data: PageData } = $props();
function getInitialTheme() {
if (data.isAuthenticated) {
return data.user?.dashboardTheme || 'none';
} else {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardTheme') || 'none';
}
return 'none';
}
}
function getInitialTheme() {
if (data.isAuthenticated) {
return data.user?.dashboardTheme || 'none';
} else {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardTheme') || 'none';
}
return 'none';
}
}
function getInitialColor() {
if (data.isAuthenticated) {
return data.user?.dashboardColor || null;
} else {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardColor') || null;
}
return null;
}
}
function getInitialColor() {
if (data.isAuthenticated) {
return data.user?.dashboardColor || null;
} else {
if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardColor') || null;
}
return null;
}
}
let currentTheme = $state(getInitialTheme());
let currentColor = $state(getInitialColor());
let currentTheme = $state(getInitialTheme());
let currentColor = $state(getInitialColor());
function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none';
function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none';
if (!data.isAuthenticated && typeof window !== 'undefined') {
localStorage.setItem('dashboardTheme', currentTheme);
}
}
if (!data.isAuthenticated && typeof window !== 'undefined') {
localStorage.setItem('dashboardTheme', currentTheme);
}
}
function handleColorUpdate(color: string | null) {
currentColor = color;
function handleColorUpdate(color: string | null) {
currentColor = color;
if (!data.isAuthenticated && typeof window !== 'undefined') {
if (color) {
localStorage.setItem('dashboardColor', color);
} else {
localStorage.removeItem('dashboardColor');
}
}
}
if (!data.isAuthenticated && typeof window !== 'undefined') {
if (color) {
localStorage.setItem('dashboardColor', color);
} else {
localStorage.removeItem('dashboardColor');
}
}
}
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
const myWishlists = $derived(() => data.wishlists || []);
const myWishlists = $derived(() => data.wishlists || []);
const claimedWishlists = $derived(() => {
return (data.savedWishlists || [])
.filter(saved => saved.wishlist?.ownerToken)
.map(saved => ({
...saved.wishlist,
isFavorite: saved.isFavorite,
isClaimed: true,
savedId: saved.id
}));
});
const claimedWishlists = $derived(() => {
return (data.savedWishlists || [])
.filter((saved) => saved.wishlist?.ownerToken)
.map((saved) => ({
...saved.wishlist,
isFavorite: saved.isFavorite,
isClaimed: true,
savedId: saved.id
}));
});
const savedWishlists = $derived(() => {
return (data.savedWishlists || []).filter(saved => !saved.wishlist?.ownerToken);
});
const savedWishlists = $derived(() => {
return (data.savedWishlists || []).filter((saved) => !saved.wishlist?.ownerToken);
});
</script>
<PageContainer theme={currentTheme} themeColor={currentColor}>
<DashboardHeader
userName={data.user?.name}
userEmail={data.user?.email}
dashboardTheme={currentTheme}
dashboardColor={currentColor}
isAuthenticated={data.isAuthenticated}
onThemeUpdate={handleThemeUpdate}
onColorUpdate={handleColorUpdate}
/>
<DashboardHeader
userName={data.user?.name}
userEmail={data.user?.email}
dashboardTheme={currentTheme}
dashboardColor={currentColor}
isAuthenticated={data.isAuthenticated}
onThemeUpdate={handleThemeUpdate}
onColorUpdate={handleColorUpdate}
/>
{#if data.isAuthenticated}
<WishlistSection
title={t.dashboard.myWishlists}
description={t.dashboard.myWishlistsDescription}
items={myWishlists()}
emptyMessage={t.dashboard.emptyWishlists}
emptyActionLabel={t.dashboard.emptyWishlistsAction}
emptyActionHref="/"
showCreateButton={true}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
>
{#snippet actions(wishlist, unlocked)}
<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>
{#if unlocked}
<form method="POST" action="?/deleteWishlist" use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}>
<input type="hidden" name="wishlistId" value={wishlist.id} />
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.delete}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
{#if data.isAuthenticated}
<WishlistSection
title={t.dashboard.myWishlists}
description={t.dashboard.myWishlistsDescription}
items={myWishlists()}
emptyMessage={t.dashboard.emptyWishlists}
emptyActionLabel={t.dashboard.emptyWishlistsAction}
emptyActionHref="/"
showCreateButton={true}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
>
{#snippet actions(wishlist, unlocked)}
<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>
{#if unlocked}
<form
method="POST"
action="?/deleteWishlist"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
>
<input type="hidden" name="wishlistId" value={wishlist.id} />
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.delete}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<LocalWishlistsSection
isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
/>
<LocalWishlistsSection
isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
/>
<WishlistSection
title={t.dashboard.claimedWishlists}
description={t.dashboard.claimedWishlistsDescription}
items={claimedWishlists()}
emptyMessage={t.dashboard.emptyClaimedWishlists}
emptyDescription={t.dashboard.emptyClaimedWishlistsDescription}
hideIfEmpty={true}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
>
{#snippet actions(wishlist, unlocked)}
<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={wishlist.savedId} />
<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>
{#if unlocked}
<form method="POST" action="?/unsaveWishlist" use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}>
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.unclaim}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<WishlistSection
title={t.dashboard.claimedWishlists}
description={t.dashboard.claimedWishlistsDescription}
items={claimedWishlists()}
emptyMessage={t.dashboard.emptyClaimedWishlists}
emptyDescription={t.dashboard.emptyClaimedWishlistsDescription}
hideIfEmpty={true}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
>
{#snippet actions(wishlist, unlocked)}
<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={wishlist.savedId} />
<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>
{#if unlocked}
<form
method="POST"
action="?/unsaveWishlist"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
>
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.unclaim}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<WishlistSection
title={t.dashboard.savedWishlists}
description={t.dashboard.savedWishlistsDescription}
items={savedWishlists()}
emptyMessage={t.dashboard.emptySavedWishlists}
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
hideIfEmpty={true}
>
{#snippet actions(saved, unlocked)}
<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>
{#if unlocked}
<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>
{/if}
</div>
{/snippet}
</WishlistSection>
{/if}
<WishlistSection
title={t.dashboard.savedWishlists}
description={t.dashboard.savedWishlistsDescription}
items={savedWishlists()}
emptyMessage={t.dashboard.emptySavedWishlists}
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
hideIfEmpty={true}
>
{#snippet actions(saved, unlocked)}
<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>
{#if unlocked}
<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>
{/if}
</div>
{/snippet}
</WishlistSection>
{/if}
</PageContainer>

View File

@@ -2,23 +2,23 @@ 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');
const registered = url.searchParams.get('registered');
const error = url.searchParams.get('error');
// Determine which OAuth providers are available
const oauthProviders = [];
// 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.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' });
}
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
};
return {
registered: registered === 'true',
error: error,
oauthProviders
};
};

View File

@@ -1,107 +1,117 @@
<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 { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
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 { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
let { data }: { data: PageData } = $props();
let { data }: { data: PageData } = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
let isSubmitting = $state(false);
let isSubmitting = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
isSubmitting = true;
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;
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;
}
}
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 flex items-center gap-1 sm:gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle class="text-2xl">{t.auth.welcomeBack}</CardTitle>
<CardDescription>{t.auth.signInPrompt}</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}
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle class="text-2xl">{t.auth.welcomeBack}</CardTitle>
<CardDescription>{t.auth.signInPrompt}</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}
{#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">{t.form.username}</Label>
<Input id="username" name="username" type="text" required />
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<Label for="username">{t.form.username}</Label>
<Input id="username" name="username" type="text" required />
</div>
<div class="space-y-2">
<Label for="password">{t.form.password}</Label>
<Input id="password" name="password" type="password" required />
</div>
<div class="space-y-2">
<Label for="password">{t.form.password}</Label>
<Input id="password" name="password" type="password" required />
</div>
<Button type="submit" class="w-full" disabled={isSubmitting}>
{isSubmitting ? t.auth.signingIn : t.auth.signIn}
</Button>
</form>
<Button type="submit" class="w-full" disabled={isSubmitting}>
{isSubmitting ? t.auth.signingIn : t.auth.signIn}
</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">{t.auth.continueWith}</span>
</div>
</div>
{#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">{t.auth.continueWith}</span>
</div>
</div>
{#each data.oauthProviders as provider}
<Button
type="button"
variant="outline"
class="w-full"
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
>
{t.auth.signIn} with {provider.name}
</Button>
{/each}
{/if}
{#each data.oauthProviders as provider}
<Button
type="button"
variant="outline"
class="w-full"
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
>
{t.auth.signIn} with {provider.name}
</Button>
{/each}
{/if}
<div class="text-center text-sm text-muted-foreground">
{t.auth.dontHaveAccount}
<a href="/signup" class="text-primary hover:underline">{t.auth.signUp}</a>
</div>
</CardContent>
</Card>
<div class="text-center text-sm text-muted-foreground">
{t.auth.dontHaveAccount}
<a href="/signup" class="text-primary hover:underline">{t.auth.signUp}</a>
</div>
</CardContent>
</Card>
</div>

View File

@@ -8,68 +8,68 @@ import { env } from '$env/dynamic/private';
import { sanitizeString, sanitizeUsername } from '$lib/server/validation';
export const load: PageServerLoad = async () => {
// Determine which OAuth providers are available
const oauthProviders = [];
// 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.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' });
}
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
}
return {
oauthProviders
};
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;
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;
let sanitizedUsername: string;
let sanitizedName: string | null;
let sanitizedUsername: string;
let sanitizedName: string | null;
try {
sanitizedName = sanitizeString(name, 100);
sanitizedUsername = sanitizeUsername(username);
} catch (error) {
return fail(400, { error: 'Invalid input', name, username });
}
try {
sanitizedName = sanitizeString(name, 100);
sanitizedUsername = sanitizeUsername(username);
} catch (error) {
return fail(400, { error: 'Invalid input', name, username });
}
if (!sanitizedName) {
return fail(400, { error: 'Name is required', name, username });
}
if (!sanitizedName) {
return fail(400, { error: 'Name is required', name, username });
}
if (!password || password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', 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 });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match', name, username });
}
const existingUser = await db.query.users.findFirst({
where: eq(users.username, sanitizedUsername)
});
const existingUser = await db.query.users.findFirst({
where: eq(users.username, sanitizedUsername)
});
if (existingUser) {
return fail(400, { error: 'Username already taken', name, username });
}
if (existingUser) {
return fail(400, { error: 'Username already taken', name, username });
}
const hashedPassword = await bcrypt.hash(password, 14);
const hashedPassword = await bcrypt.hash(password, 14);
await db.insert(users).values({
name: sanitizedName,
username: sanitizedUsername,
password: hashedPassword
});
await db.insert(users).values({
name: sanitizedName,
username: sanitizedUsername,
password: hashedPassword
});
throw redirect(303, '/signin?registered=true');
}
throw redirect(303, '/signin?registered=true');
}
};

View File

@@ -1,86 +1,100 @@
<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 { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { ActionData, PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
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 { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { ActionData, PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
let { form, data }: { form: ActionData; data: PageData } = $props();
let { form, data }: { form: ActionData; data: PageData } = $props();
const t = $derived(languageStore.t);
const t = $derived(languageStore.t);
</script>
<div class="min-h-screen flex items-center justify-center p-4">
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle class="text-2xl">{t.auth.createAccount}</CardTitle>
<CardDescription>{t.auth.signUpPrompt}</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}
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
<LanguageToggle />
<ThemeToggle />
</div>
<Card class="w-full max-w-md">
<CardHeader>
<CardTitle class="text-2xl">{t.auth.createAccount}</CardTitle>
<CardDescription>{t.auth.signUpPrompt}</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">{t.form.name}</Label>
<Input id="name" name="name" type="text" required value={form?.name || ''} />
</div>
<form method="POST" class="space-y-4">
<div class="space-y-2">
<Label for="name">{t.form.name}</Label>
<Input id="name" name="name" type="text" required value={form?.name || ''} />
</div>
<div class="space-y-2">
<Label for="username">{t.form.username}</Label>
<Input id="username" name="username" type="text" required value={form?.username || ''} />
</div>
<div class="space-y-2">
<Label for="username">{t.form.username}</Label>
<Input id="username" name="username" type="text" required value={form?.username || ''} />
</div>
<div class="space-y-2">
<Label for="password">{t.form.password}</Label>
<Input id="password" name="password" type="password" required minlength={8} />
</div>
<div class="space-y-2">
<Label for="password">{t.form.password}</Label>
<Input id="password" name="password" type="password" required minlength={8} />
</div>
<div class="space-y-2">
<Label for="confirmPassword">{t.form.confirmPassword}</Label>
<Input id="confirmPassword" name="confirmPassword" type="password" required minlength={8} />
</div>
<div class="space-y-2">
<Label for="confirmPassword">{t.form.confirmPassword}</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minlength={8}
/>
</div>
<Button type="submit" class="w-full">{t.auth.signUp}</Button>
</form>
<Button type="submit" class="w-full">{t.auth.signUp}</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">{t.auth.continueWith}</span>
</div>
</div>
{#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">{t.auth.continueWith}</span>
</div>
</div>
{#each data.oauthProviders as provider}
<Button
type="button"
variant="outline"
class="w-full"
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
>
{t.auth.signUp} with {provider.name}
</Button>
{/each}
{/if}
{#each data.oauthProviders as provider}
<Button
type="button"
variant="outline"
class="w-full"
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
>
{t.auth.signUp} with {provider.name}
</Button>
{/each}
{/if}
<div class="text-center text-sm text-muted-foreground">
{t.auth.alreadyHaveAccount}
<a href="/signin" class="text-primary hover:underline">{t.auth.signIn}</a>
</div>
</CardContent>
</Card>
<div class="text-center text-sm text-muted-foreground">
{t.auth.alreadyHaveAccount}
<a href="/signin" class="text-primary hover:underline">{t.auth.signIn}</a>
</div>
</CardContent>
</Card>
</div>

View File

@@ -5,182 +5,173 @@ import { wishlists, items, reservations, savedWishlists } from '$lib/db/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
}
}
}
});
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');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
const session = await locals.auth();
let isSaved = false;
let isClaimed = false;
let savedWishlistId: string | null = null;
const session = await locals.auth();
let isSaved = false;
let isClaimed = 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;
isClaimed = !!saved?.ownerToken;
savedWishlistId = saved?.id || 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;
isClaimed = !!saved?.ownerToken;
savedWishlistId = saved?.id || null;
}
return {
wishlist,
isSaved,
isClaimed,
savedWishlistId,
isAuthenticated: !!session?.user,
currentUserId: session?.user?.id || null
};
return {
wishlist,
isSaved,
isClaimed,
savedWishlistId,
isAuthenticated: !!session?.user,
currentUserId: session?.user?.id || null
};
};
export const actions: Actions = {
reserve: async ({ request, locals }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
const reserverName = formData.get('reserverName') as string;
reserve: async ({ request, locals }) => {
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' };
}
if (!itemId) {
return { success: false, error: 'Item ID is required' };
}
const session = await locals.auth();
const session = await locals.auth();
const existingReservation = await db.query.reservations.findFirst({
where: eq(reservations.itemId, itemId)
});
const existingReservation = await db.query.reservations.findFirst({
where: eq(reservations.itemId, itemId)
});
if (existingReservation) {
return { success: false, error: 'This item is already reserved' };
}
if (existingReservation) {
return { success: false, error: 'This item is already reserved' };
}
await db.transaction(async (tx) => {
await tx.insert(reservations).values({
itemId,
userId: session?.user?.id || null,
reserverName: reserverName?.trim() || null
});
await db.transaction(async (tx) => {
await tx.insert(reservations).values({
itemId,
userId: session?.user?.id || null,
reserverName: reserverName?.trim() || null
});
await tx
.update(items)
.set({ isReserved: true })
.where(eq(items.id, itemId));
});
await tx.update(items).set({ isReserved: true }).where(eq(items.id, itemId));
});
return { success: true };
},
return { success: true };
},
unreserve: async ({ request, locals }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
unreserve: async ({ request, locals }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
if (!itemId) {
return { success: false, error: 'Item ID is required' };
}
if (!itemId) {
return { success: false, error: 'Item ID is required' };
}
const session = await locals.auth();
const session = await locals.auth();
const reservation = await db.query.reservations.findFirst({
where: eq(reservations.itemId, itemId)
});
const reservation = await db.query.reservations.findFirst({
where: eq(reservations.itemId, itemId)
});
if (!reservation) {
return { success: false, error: 'Reservation not found' };
}
if (!reservation) {
return { success: false, error: 'Reservation not found' };
}
if (reservation.userId) {
if (!session?.user?.id || session.user.id !== reservation.userId) {
return {
success: false,
error: 'You can only cancel your own reservations'
};
}
}
if (reservation.userId) {
if (!session?.user?.id || session.user.id !== reservation.userId) {
return {
success: false,
error: 'You can only cancel your own reservations'
};
}
}
await db.transaction(async (tx) => {
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
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));
});
await tx.update(items).set({ isReserved: false }).where(eq(items.id, itemId));
});
return { success: true };
},
return { success: true };
},
saveWishlist: async ({ request, locals, params }) => {
const session = await locals.auth();
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' };
}
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;
const formData = await request.formData();
const wishlistId = formData.get('wishlistId') as string;
if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' };
}
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)
)
});
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' };
}
if (existing) {
return { success: false, error: 'Wishlist already saved' };
}
// Save without ownerToken - user is accessing via public link, so no edit access
await db.insert(savedWishlists).values({
userId: session.user.id,
wishlistId,
ownerToken: null // Explicitly set to null - no edit access from reservation view
});
// Save without ownerToken - user is accessing via public link, so no edit access
await db.insert(savedWishlists).values({
userId: session.user.id,
wishlistId,
ownerToken: null // Explicitly set to null - no edit access from reservation view
});
return { success: true };
},
return { success: true };
},
unsaveWishlist: async ({ request, locals }) => {
const session = await locals.auth();
unsaveWishlist: async ({ request, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
return { success: false, error: 'You must be logged in' };
}
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;
const formData = await request.formData();
const savedWishlistId = formData.get('savedWishlistId') as string;
if (!savedWishlistId) {
return { success: false, error: 'Saved wishlist ID is required' };
}
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)
)
);
await db
.delete(savedWishlists)
.where(
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id))
);
return { success: true };
}
return { success: true };
}
};

View File

@@ -1,137 +1,128 @@
<script lang="ts">
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "$lib/components/ui/card";
import { Button } from "$lib/components/ui/button";
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";
import { languageStore } from '$lib/stores/language.svelte';
import SearchBar from "$lib/components/ui/SearchBar.svelte";
import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
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';
import { languageStore } from '$lib/stores/language.svelte';
import SearchBar from '$lib/components/ui/SearchBar.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
let { data }: { data: PageData } = $props();
let { data }: { data: PageData } = $props();
let searchQuery = $state('');
let searchQuery = $state('');
const t = $derived(languageStore.t);
const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
const t = $derived(languageStore.t);
const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
const filteredItems = $derived(
data.wishlist.items?.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
) || []
);
const filteredItems = $derived(
data.wishlist.items?.filter(
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
) || []
);
</script>
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
<Navigation
isAuthenticated={data.isAuthenticated}
showDashboardLink={true}
color={data.wishlist.color}
/>
<Navigation
isAuthenticated={data.isAuthenticated}
showDashboardLink={true}
color={data.wishlist.color}
/>
<Card style={headerCardStyle}>
<CardContent class="pt-6">
<div class="flex flex-wrap 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.isClaimed}
<Button
variant="outline"
size="sm"
disabled
>
{t.wishlist.youClaimedThis}
</Button>
{:else 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">
{t.wishlist.unsaveWishlist}
</Button>
</form>
{:else}
<form method="POST" action="?/saveWishlist" use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}>
<input type="hidden" name="wishlistId" value={data.wishlist.id} />
<Button type="submit" variant="outline" size="sm">
{t.wishlist.saveWishlist}
</Button>
</form>
{/if}
{:else}
<Button
variant="outline"
size="sm"
onclick={() => (window.location.href = "/signin")}
>
{t.wishlist.signInToSave}
</Button>
{/if}
</div>
</CardContent>
</Card>
<Card style={headerCardStyle}>
<CardContent class="pt-6">
<div class="flex flex-wrap 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.isClaimed}
<Button variant="outline" size="sm" disabled>
{t.wishlist.youClaimedThis}
</Button>
{:else 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">
{t.wishlist.unsaveWishlist}
</Button>
</form>
{:else}
<form
method="POST"
action="?/saveWishlist"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
};
}}
>
<input type="hidden" name="wishlistId" value={data.wishlist.id} />
<Button type="submit" variant="outline" size="sm">
{t.wishlist.saveWishlist}
</Button>
</form>
{/if}
{:else}
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')}>
{t.wishlist.signInToSave}
</Button>
{/if}
</div>
</CardContent>
</Card>
{#if data.wishlist.items && data.wishlist.items.length > 0}
<SearchBar bind:value={searchQuery} />
{/if}
{#if data.wishlist.items && data.wishlist.items.length > 0}
<SearchBar bind:value={searchQuery} />
{/if}
<div class="space-y-4">
{#if filteredItems.length > 0}
{#each filteredItems as item}
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
<ReservationButton
itemId={item.id}
isReserved={item.isReserved}
reserverName={item.reservations?.[0]?.reserverName}
reservationUserId={item.reservations?.[0]?.userId}
currentUserId={data.currentUserId}
/>
</WishlistItem>
{/each}
{:else if data.wishlist.items && data.wishlist.items.length > 0}
<Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
<CardContent class="p-12 relative z-10">
<EmptyState
message="No wishes match your search."
/>
</CardContent>
</Card>
{:else}
<Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} showPattern={false} />
<CardContent class="p-12 relative z-10">
<EmptyState
message={t.wishlist.emptyWishes}
/>
</CardContent>
</Card>
{/if}
</div>
<div class="space-y-4">
{#if filteredItems.length > 0}
{#each filteredItems as item}
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
<ReservationButton
itemId={item.id}
isReserved={item.isReserved}
reserverName={item.reservations?.[0]?.reserverName}
reservationUserId={item.reservations?.[0]?.userId}
currentUserId={data.currentUserId}
/>
</WishlistItem>
{/each}
{:else if data.wishlist.items && data.wishlist.items.length > 0}
<Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
<CardContent class="p-12 relative z-10">
<EmptyState message="No wishes match your search." />
</CardContent>
</Card>
{:else}
<Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard
themeName={data.wishlist.theme}
color={data.wishlist.color}
showPattern={false}
/>
<CardContent class="p-12 relative z-10">
<EmptyState message={t.wishlist.emptyWishes} />
</CardContent>
</Card>
{/if}
</div>
</PageContainer>

View File

@@ -5,310 +5,309 @@ import { wishlists, items, savedWishlists } from '$lib/db/schema';
import { eq, and } 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)]
}
}
});
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');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
const session = await locals.auth();
let hasClaimed = false;
let isOwner = false;
const session = await locals.auth();
let hasClaimed = false;
let isOwner = false;
if (session?.user?.id) {
// Check if user is the owner
isOwner = wishlist.userId === session.user.id;
if (session?.user?.id) {
// Check if user is the owner
isOwner = wishlist.userId === session.user.id;
// Check if user has claimed this wishlist
const savedWishlist = await db.query.savedWishlists.findFirst({
where: and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
});
hasClaimed = !!savedWishlist;
}
// Check if user has claimed this wishlist
const savedWishlist = await db.query.savedWishlists.findFirst({
where: and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
});
hasClaimed = !!savedWishlist;
}
return {
wishlist,
publicUrl: `/wishlist/${wishlist.publicToken}`,
isAuthenticated: !!session?.user,
hasClaimed,
isOwner
};
return {
wishlist,
publicUrl: `/wishlist/${wishlist.publicToken}`,
isAuthenticated: !!session?.user,
hasClaimed,
isOwner
};
};
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;
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' };
}
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
}
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token),
with: {
items: true
}
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
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);
// 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)
});
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 };
},
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;
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 (!itemId) {
return { success: false, error: 'Item ID is required' };
}
if (!title?.trim()) {
return { success: false, error: 'Title 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)
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
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));
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 };
},
return { success: true };
},
deleteItem: async ({ params, request }) => {
const formData = await request.formData();
const itemId = formData.get('itemId') as string;
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' };
}
if (!itemId) {
return { success: false, error: 'Item ID is required' };
}
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
await db.delete(items).where(eq(items.id, itemId));
await db.delete(items).where(eq(items.id, itemId));
return { success: true };
},
return { success: true };
},
reorderItems: async ({ params, request }) => {
const formData = await request.formData();
const itemsJson = formData.get('items') as string;
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' };
}
if (!itemsJson) {
return { success: false, error: 'Items data is required' };
}
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
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));
}
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 };
},
return { success: true };
},
deleteWishlist: async ({ params }) => {
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
deleteWishlist: async ({ params }) => {
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
return { success: true, redirect: '/dashboard' };
},
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 theme = formData.get('theme');
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 theme = formData.get('theme');
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
const updates: any = {
updatedAt: new Date()
};
const updates: any = {
updatedAt: new Date()
};
if (color !== null) {
updates.color = color?.toString().trim() || null;
}
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 (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 (description !== null) {
updates.description = description?.toString().trim() || null;
}
if (endDate !== null) {
const endDateStr = endDate?.toString().trim();
updates.endDate = endDateStr ? new Date(endDateStr) : null;
}
if (endDate !== null) {
const endDateStr = endDate?.toString().trim();
updates.endDate = endDateStr ? new Date(endDateStr) : null;
}
if (theme !== null) {
updates.theme = theme?.toString().trim() || 'none';
}
if (theme !== null) {
updates.theme = theme?.toString().trim() || 'none';
}
await db.update(wishlists)
.set(updates)
.where(eq(wishlists.id, wishlist.id));
await db.update(wishlists).set(updates).where(eq(wishlists.id, wishlist.id));
return { success: true };
},
return { success: true };
},
claimWishlist: async ({ params, locals }) => {
const session = await locals.auth();
claimWishlist: async ({ params, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw error(401, 'You must be signed in to claim a wishlist');
}
if (!session?.user?.id) {
throw error(401, 'You must be signed in to claim a wishlist');
}
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
// Check if already claimed
const existing = await db.query.savedWishlists.findFirst({
where: and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
});
// Check if already claimed
const existing = await db.query.savedWishlists.findFirst({
where: and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
});
if (existing) {
return { success: true, message: 'Already claimed' };
}
if (existing) {
return { success: true, message: 'Already claimed' };
}
// Store the ownerToken - user is accessing via edit link, so they get edit access
await db.insert(savedWishlists).values({
userId: session.user.id,
wishlistId: wishlist.id,
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
isFavorite: false
});
// Store the ownerToken - user is accessing via edit link, so they get edit access
await db.insert(savedWishlists).values({
userId: session.user.id,
wishlistId: wishlist.id,
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
isFavorite: false
});
return { success: true, message: 'Wishlist claimed successfully' };
},
return { success: true, message: 'Wishlist claimed successfully' };
},
unclaimWishlist: async ({ params, locals }) => {
const session = await locals.auth();
unclaimWishlist: async ({ params, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw error(401, 'You must be signed in');
}
if (!session?.user?.id) {
throw error(401, 'You must be signed in');
}
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
await db.delete(savedWishlists).where(
and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
);
await db
.delete(savedWishlists)
.where(
and(eq(savedWishlists.userId, session.user.id), eq(savedWishlists.wishlistId, wishlist.id))
);
return { success: true, message: 'Wishlist unclaimed' };
}
return { success: true, message: 'Wishlist unclaimed' };
}
};

View File

@@ -1,193 +1,190 @@
<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 ClaimWishlistSection from "$lib/components/wishlist/ClaimWishlistSection.svelte";
import DangerZone from "$lib/components/wishlist/DangerZone.svelte";
import type { Item } from "$lib/server/schema";
import SearchBar from "$lib/components/ui/SearchBar.svelte";
import * as wishlistUpdates from "$lib/utils/wishlistUpdates";
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 ClaimWishlistSection from '$lib/components/wishlist/ClaimWishlistSection.svelte';
import DangerZone from '$lib/components/wishlist/DangerZone.svelte';
import type { Item } from '$lib/server/schema';
import SearchBar from '$lib/components/ui/SearchBar.svelte';
import * as wishlistUpdates from '$lib/utils/wishlistUpdates';
let { data }: { data: PageData } = $props();
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 currentTheme = $state(data.wishlist.theme || 'none');
let currentColor = $state(data.wishlist.color);
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 currentTheme = $state(data.wishlist.theme || 'none');
let currentColor = $state(data.wishlist.color);
let items = $state<Item[]>([]);
let items = $state<Item[]>([]);
$effect.pre(() => {
const sorted = [...data.wishlist.items].sort(
(a, b) => Number(a.order) - Number(b.order),
);
items = sorted;
});
$effect.pre(() => {
const sorted = [...data.wishlist.items].sort((a, b) => Number(a.order) - Number(b.order));
items = sorted;
});
let filteredItems = $derived(
searchQuery.trim()
? items.filter(item =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: items
);
let filteredItems = $derived(
searchQuery.trim()
? items.filter(
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: items
);
function handleItemAdded() {
showAddForm = false;
}
function handleItemAdded() {
showAddForm = false;
}
function handleItemUpdated() {
editingItem = null;
}
function handleItemUpdated() {
editingItem = null;
}
function startEditing(item: Item) {
editingItem = item;
showAddForm = false;
setTimeout(() => {
editFormElement?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 100);
}
function startEditing(item: Item) {
editingItem = item;
showAddForm = false;
setTimeout(() => {
editFormElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}, 100);
}
function handleColorChange(itemId: string, newColor: string) {
items = items.map((item) =>
item.id === itemId ? { ...item, color: newColor } : item,
);
}
function handleColorChange(itemId: string, newColor: string) {
items = items.map((item) => (item.id === itemId ? { ...item, color: newColor } : item));
}
function cancelEditing() {
editingItem = null;
}
function cancelEditing() {
editingItem = null;
}
async function handleReorder(items: Item[]) {
const updates = items.map((item, index) => ({
id: item.id,
order: index,
}));
await wishlistUpdates.reorderItems(updates);
}
async function handleReorder(items: Item[]) {
const updates = items.map((item, index) => ({
id: item.id,
order: index
}));
await wishlistUpdates.reorderItems(updates);
}
function handleToggleAddForm() {
showAddForm = !showAddForm;
if (showAddForm) {
setTimeout(() => {
addFormElement?.scrollIntoView({
behavior: "smooth",
block: "center",
});
}, 100);
}
}
function handleToggleAddForm() {
showAddForm = !showAddForm;
if (showAddForm) {
setTimeout(() => {
addFormElement?.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}, 100);
}
}
async function handlePositionChange(newPosition: number) {
if (!editingItem) return;
async function handlePositionChange(newPosition: number) {
if (!editingItem) return;
const currentIndex = items.findIndex(item => item.id === editingItem.id);
if (currentIndex === -1) return;
const currentIndex = items.findIndex((item) => item.id === editingItem.id);
if (currentIndex === -1) return;
const newIndex = newPosition - 1; // Convert to 0-based index
const newIndex = newPosition - 1; // Convert to 0-based index
const newItems = [...items];
const [movedItem] = newItems.splice(currentIndex, 1);
newItems.splice(newIndex, 0, movedItem);
const newItems = [...items];
const [movedItem] = newItems.splice(currentIndex, 1);
newItems.splice(newIndex, 0, movedItem);
items = newItems;
await handleReorder(newItems);
}
items = newItems;
await handleReorder(newItems);
}
async function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none';
await wishlistUpdates.updateTheme(theme);
}
async function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none';
await wishlistUpdates.updateTheme(theme);
}
async function handleColorUpdate(color: string | null) {
currentColor = color;
await wishlistUpdates.updateColor(color);
}
async function handleColorUpdate(color: string | null) {
currentColor = color;
await wishlistUpdates.updateColor(color);
}
</script>
<PageContainer maxWidth="4xl" theme={currentTheme} themeColor={currentColor}>
<Navigation
isAuthenticated={data.isAuthenticated}
showDashboardLink={true}
color={currentColor}
/>
<Navigation
isAuthenticated={data.isAuthenticated}
showDashboardLink={true}
color={currentColor}
/>
<WishlistHeader
wishlist={data.wishlist}
onTitleUpdate={wishlistUpdates.updateTitle}
onDescriptionUpdate={wishlistUpdates.updateDescription}
onColorUpdate={handleColorUpdate}
onEndDateUpdate={wishlistUpdates.updateEndDate}
onThemeUpdate={handleThemeUpdate}
/>
<WishlistHeader
wishlist={data.wishlist}
onTitleUpdate={wishlistUpdates.updateTitle}
onDescriptionUpdate={wishlistUpdates.updateDescription}
onColorUpdate={handleColorUpdate}
onEndDateUpdate={wishlistUpdates.updateEndDate}
onThemeUpdate={handleThemeUpdate}
/>
<ShareLinks
publicUrl={data.publicUrl}
ownerUrl="/wishlist/{data.wishlist.ownerToken}/edit"
wishlistColor={currentColor}
/>
<ShareLinks
publicUrl={data.publicUrl}
ownerUrl="/wishlist/{data.wishlist.ownerToken}/edit"
wishlistColor={currentColor}
/>
<ClaimWishlistSection
isAuthenticated={data.isAuthenticated}
isOwner={data.isOwner}
hasClaimed={data.hasClaimed}
ownerToken={data.wishlist.ownerToken}
/>
<ClaimWishlistSection
isAuthenticated={data.isAuthenticated}
isOwner={data.isOwner}
hasClaimed={data.hasClaimed}
ownerToken={data.wishlist.ownerToken}
/>
<WishlistActionButtons
bind:rearranging={rearranging}
showAddForm={showAddForm}
onToggleAddForm={handleToggleAddForm}
/>
<WishlistActionButtons bind:rearranging {showAddForm} onToggleAddForm={handleToggleAddForm} />
{#if showAddForm}
<div bind:this={addFormElement}>
<AddItemForm onSuccess={handleItemAdded} wishlistColor={currentColor} wishlistTheme={currentTheme} />
</div>
{/if}
{#if showAddForm}
<div bind:this={addFormElement}>
<AddItemForm
onSuccess={handleItemAdded}
wishlistColor={currentColor}
wishlistTheme={currentTheme}
/>
</div>
{/if}
{#if editingItem}
<div bind:this={editFormElement}>
<EditItemForm
item={editingItem}
onSuccess={handleItemUpdated}
onCancel={cancelEditing}
onColorChange={handleColorChange}
currentPosition={items.findIndex(item => item.id === editingItem.id) + 1}
totalItems={items.length}
onPositionChange={handlePositionChange}
wishlistColor={currentColor}
wishlistTheme={currentTheme}
/>
</div>
{/if}
{#if editingItem}
<div bind:this={editFormElement}>
<EditItemForm
item={editingItem}
onSuccess={handleItemUpdated}
onCancel={cancelEditing}
onColorChange={handleColorChange}
currentPosition={items.findIndex((item) => item.id === editingItem.id) + 1}
totalItems={items.length}
onPositionChange={handlePositionChange}
wishlistColor={currentColor}
wishlistTheme={currentTheme}
/>
</div>
{/if}
{#if items.length > 5}
<SearchBar bind:value={searchQuery} />
{/if}
{#if items.length > 5}
<SearchBar bind:value={searchQuery} />
{/if}
<EditableItemsList
bind:items={filteredItems}
{rearranging}
onStartEditing={startEditing}
onReorder={handleReorder}
theme={currentTheme}
wishlistColor={currentColor}
/>
<EditableItemsList
bind:items={filteredItems}
{rearranging}
onStartEditing={startEditing}
onReorder={handleReorder}
theme={currentTheme}
wishlistColor={currentColor}
/>
<DangerZone bind:unlocked={rearranging} />
<DangerZone bind:unlocked={rearranging} />
</PageContainer>