wip: not loading themes until reload, missing in dashboard, bad alignment and scaling

This commit is contained in:
rasmusq
2025-11-28 00:26:43 +01:00
parent 85f8671c72
commit 7c6ff9458f
21 changed files with 417 additions and 20 deletions

View File

@@ -3,26 +3,30 @@
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,
children
}: {
title: string;
description?: string | null;
itemCount: number;
color?: string | null;
theme?: string | null;
children?: Snippet;
} = $props();
const cardStyle = $derived(getCardStyle(color));
</script>
<Card style={cardStyle} class="h-full flex flex-col">
<CardHeader class="flex-shrink-0">
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
<ThemeCard themeName={theme} color={color} />
<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>
@@ -35,7 +39,7 @@
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
{/if}
</CardHeader>
<CardContent class="space-y-2 flex-1 flex flex-col justify-end">
<CardContent class="space-y-2 flex-1 flex flex-col justify-end relative z-10">
{#if children}
<div>
{@render children()}

View File

@@ -149,6 +149,7 @@
description={getWishlistDescription(item)}
itemCount={wishlist.items?.length || 0}
color={wishlist.color}
theme={wishlist.theme}
>
{@render actions(item, unlocked)}
</WishlistCard>

View File

@@ -2,13 +2,41 @@
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 { signOut } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
import { enhance } from '$app/forms';
let { userName, userEmail }: { userName?: string | null; userEmail?: string | null } = $props();
let {
userName,
userEmail,
dashboardTheme = 'none'
}: {
userName?: string | null;
userEmail?: string | null;
dashboardTheme?: string;
} = $props();
const t = $derived(languageStore.t);
const isAuthenticated = $derived(!!userName || !!userEmail);
let currentTheme = $state(dashboardTheme);
async function handleThemeChange(theme: string) {
currentTheme = theme;
// Submit form to update theme
const formData = new FormData();
formData.append('theme', theme);
await fetch('?/updateDashboardTheme', {
method: 'POST',
body: formData
});
// Reload to apply new theme
window.location.reload();
}
</script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -21,6 +49,9 @@
{/if}
</div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{#if isAuthenticated}
<ThemePicker value={currentTheme} onValueChange={handleThemeChange} />
{/if}
<LanguageToggle />
<ThemeToggle />
{#if isAuthenticated}

View File

@@ -1,11 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
let { children, maxWidth = '6xl' }: { children: Snippet; maxWidth?: string } = $props();
let {
children,
maxWidth = '6xl',
theme = null,
themeColor = null
}: {
children: Snippet;
maxWidth?: string;
theme?: string | null;
themeColor?: string | null;
} = $props();
</script>
<div class="min-h-screen p-4 md:p-8">
<div class="max-w-{maxWidth} mx-auto space-y-6">
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden">
<ThemeBackground themeName={theme} color={themeColor} />
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
{@render children()}
</div>
</div>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import TopPattern from './svgs/TopPattern.svelte';
import BottomPattern from './svgs/BottomPattern.svelte';
import { getTheme, getThemeColor } from '$lib/utils/themes';
let {
themeName,
color,
showTop = true,
showBottom = true
}: {
themeName?: string | null;
color?: string | null;
showTop?: boolean;
showBottom?: boolean;
} = $props();
const theme = $derived(getTheme(themeName));
const themeColor = $derived(getThemeColor(color));
</script>
{#if theme.pattern !== 'none'}
{#if showTop}
<TopPattern
pattern={theme.pattern}
color={themeColor}
opacity={theme.opacity}
/>
{/if}
{#if showBottom}
<BottomPattern
pattern={theme.pattern}
color={themeColor}
opacity={theme.opacity}
/>
{/if}
{/if}

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import CardPattern from './svgs/CardPattern.svelte';
import { getTheme, getThemeColor } from '$lib/utils/themes';
let {
themeName,
color
}: {
themeName?: string | null;
color?: string | null;
} = $props();
const theme = $derived(getTheme(themeName));
const themeColor = $derived(getThemeColor(color));
</script>
{#if theme.pattern !== 'none'}
<CardPattern
pattern={theme.pattern}
color={themeColor}
opacity={theme.opacity}
/>
{/if}

View File

@@ -0,0 +1,38 @@
<script lang="ts">
let {
color = 'currentColor',
opacity = 0.1,
pattern = 'waves'
}: {
color?: string;
opacity?: number;
pattern?: 'waves' | 'geometric' | 'dots';
} = $props();
</script>
<div class="absolute bottom-0 left-0 right-0 pointer-events-none overflow-hidden" style="opacity: {opacity};">
{#if pattern === 'waves'}
<svg class="w-full h-auto" viewBox="0 0 1440 200" xmlns="http://www.w3.org/2000/svg">
<path
fill={color}
d="M0,100 C240,50 480,150 720,100 C960,50 1200,150 1440,100 L1440,200 L0,200 Z"
/>
</svg>
{:else if pattern === 'geometric'}
<svg class="w-full h-auto" viewBox="0 0 1440 200" xmlns="http://www.w3.org/2000/svg">
<path
fill={color}
d="M0,200 L720,50 L1440,200 Z"
/>
</svg>
{:else if pattern === 'dots'}
<svg class="w-full h-auto" viewBox="0 0 1440 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots-pattern-bottom" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="20" cy="20" r="3" fill={color} />
</pattern>
</defs>
<rect width="1440" height="200" fill="url(#dots-pattern-bottom)" />
</svg>
{/if}
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
let {
color = 'currentColor',
opacity = 0.08,
pattern = 'waves'
}: {
color?: string;
opacity?: number;
pattern?: 'waves' | 'geometric' | 'dots';
} = $props();
</script>
<div class="absolute bottom-0 left-0 right-0 pointer-events-none overflow-hidden rounded-b-lg" style="opacity: {opacity};">
{#if pattern === 'waves'}
<svg class="w-full h-auto" viewBox="0 0 400 80" xmlns="http://www.w3.org/2000/svg">
<path
fill={color}
d="M0,40 C100,20 200,60 300,40 C350,30 375,35 400,40 L400,80 L0,80 Z"
/>
</svg>
{:else if pattern === 'geometric'}
<svg class="w-full h-auto" viewBox="0 0 400 80" xmlns="http://www.w3.org/2000/svg">
<path
fill={color}
d="M0,80 L200,20 L400,80 Z"
/>
</svg>
{:else if pattern === 'dots'}
<svg class="w-full h-auto" viewBox="0 0 400 80" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots-pattern-card" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<circle cx="10" cy="10" r="2" fill={color} />
</pattern>
</defs>
<rect width="400" height="80" fill="url(#dots-pattern-card)" />
</svg>
{/if}
</div>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
let {
color = 'currentColor',
opacity = 0.1,
pattern = 'waves'
}: {
color?: string;
opacity?: number;
pattern?: 'waves' | 'geometric' | 'dots';
} = $props();
</script>
<div class="absolute top-0 left-0 right-0 pointer-events-none overflow-hidden" style="opacity: {opacity};">
{#if pattern === 'waves'}
<svg class="w-full h-auto" viewBox="0 0 1440 200" xmlns="http://www.w3.org/2000/svg">
<path
fill={color}
d="M0,100 C240,150 480,50 720,100 C960,150 1200,50 1440,100 L1440,0 L0,0 Z"
/>
</svg>
{:else if pattern === 'geometric'}
<svg class="w-full h-auto" viewBox="0 0 1440 200" xmlns="http://www.w3.org/2000/svg">
<path
fill={color}
d="M0,0 L720,150 L1440,0 Z"
/>
</svg>
{:else if pattern === 'dots'}
<svg class="w-full h-auto" viewBox="0 0 1440 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dots-pattern-top" x="0" y="0" width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="20" cy="20" r="3" fill={color} />
</pattern>
</defs>
<rect width="1440" height="200" fill="url(#dots-pattern-top)" />
</svg>
{/if}
</div>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Palette } from 'lucide-svelte';
import { AVAILABLE_THEMES } from '$lib/utils/themes';
let {
value = 'none',
onValueChange
}: {
value?: string;
onValueChange: (theme: string) => void;
} = $props();
let showMenu = $state(false);
function toggleMenu() {
showMenu = !showMenu;
}
function handleSelect(themeName: string) {
onValueChange(themeName);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.theme-picker-menu')) {
showMenu = false;
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="relative theme-picker-menu">
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Select theme pattern">
<Palette class="h-[1.2rem] w-[1.2rem]" />
</Button>
{#if showMenu}
<div
class="absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 shadow-lg z-50"
>
<div class="py-1">
{#each Object.entries(AVAILABLE_THEMES) as [key, theme]}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors flex items-center justify-between"
class:font-bold={value === key}
class:bg-slate-100={value === key}
class:dark:bg-slate-900={value === key}
onclick={() => handleSelect(key)}
>
<span>{theme.name}</span>
{#if value === key}
<span class="ml-2"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -12,12 +12,14 @@
items = $bindable([]),
rearranging,
onStartEditing,
onReorder
onReorder,
theme = null
}: {
items: Item[];
rearranging: boolean;
onStartEditing: (item: Item) => void;
onReorder: (items: Item[]) => Promise<void>;
theme?: string | null;
} = $props();
const t = $derived(languageStore.t);
@@ -28,7 +30,7 @@
<div class="space-y-4">
{#each items as item (item.id)}
<div animate:flip={{ duration: 300 }}>
<WishlistItem {item} showDragHandle={false}>
<WishlistItem {item} {theme} showDragHandle={false}>
<div class="flex gap-2">
<Button
type="button"

View File

@@ -5,6 +5,7 @@
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 type { Wishlist } from "$lib/server/schema";
import { languageStore } from '$lib/stores/language.svelte';
@@ -13,13 +14,15 @@
onTitleUpdate,
onDescriptionUpdate,
onColorUpdate,
onEndDateUpdate
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);
@@ -29,6 +32,7 @@
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]
@@ -112,7 +116,16 @@
{/if}
</button>
</div>
<div class="flex-shrink-0">
<div class="flex items-center gap-2 flex-shrink-0">
<ThemePicker
value={wishlistTheme}
onValueChange={async (theme) => {
wishlistTheme = theme;
await onThemeUpdate(theme);
// Force reactivity by updating the wishlist object
wishlist.theme = theme;
}}
/>
<ColorPicker
bind:color={wishlistColor}
onchange={() => onColorUpdate(wishlistColor)}

View File

@@ -5,12 +5,14 @@
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;
}
let {
@@ -18,6 +20,7 @@
showImage = true,
children,
showDragHandle = false,
theme = null
}: Props = $props();
const t = $derived(languageStore.t);
@@ -51,8 +54,9 @@
const cardStyle = $derived(getCardStyle(item.color));
</script>
<Card style={cardStyle}>
<CardContent class="p-6">
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={item.color} />
<CardContent class="p-6 relative z-10">
<div class="flex gap-4">
{#if showDragHandle}
<div

View File

@@ -12,7 +12,8 @@ export const users = pgTable('user', {
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password'),
username: text('username').unique()
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none')
});
export const accounts = pgTable(
@@ -70,6 +71,7 @@ export const wishlists = pgTable('wishlists', {
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()

51
src/lib/utils/themes.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Theme configuration for SVG overlays
*/
export type ThemePattern = 'waves' | 'geometric' | 'dots' | 'none';
export interface Theme {
name: string;
pattern: ThemePattern;
opacity: number;
}
export const AVAILABLE_THEMES: Record<string, Theme> = {
none: {
name: 'None',
pattern: 'none',
opacity: 0
},
waves: {
name: 'Waves',
pattern: 'waves',
opacity: 0.1
},
geometric: {
name: 'Geometric',
pattern: 'geometric',
opacity: 0.1
},
dots: {
name: 'Dots',
pattern: 'dots',
opacity: 0.15
}
};
export const DEFAULT_THEME = 'none';
export function getTheme(themeName?: string | null): Theme {
if (!themeName || !AVAILABLE_THEMES[themeName]) {
return AVAILABLE_THEMES[DEFAULT_THEME];
}
return AVAILABLE_THEMES[themeName];
}
/**
* Get color from a CSS color string or class
* For now, we'll use currentColor which inherits from the parent
*/
export function getThemeColor(color?: string | null): string {
return color || 'currentColor';
}

View File

@@ -38,6 +38,10 @@ export async function updateEndDate(endDate: string | null): Promise<boolean> {
return updateWishlist({ endDate: endDate || "" });
}
export async function updateTheme(theme: string | null): Promise<boolean> {
return updateWishlist({ theme: theme || "none" });
}
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
const response = await fetch("?/reorderItems", {
method: "POST",

View File

@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db';
import { wishlists, savedWishlists } from '$lib/server/schema';
import { wishlists, savedWishlists, users } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
@@ -149,6 +149,26 @@ export const actions: Actions = {
eq(wishlists.userId, session.user.id)
));
return { success: true };
},
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;
if (!theme) {
return { success: false, error: 'Theme is required' };
}
await db.update(users)
.set({ dashboardTheme: theme })
.where(eq(users.id, session.user.id));
return { success: true };
}
};

View File

@@ -34,8 +34,12 @@
});
</script>
<PageContainer>
<DashboardHeader userName={data.user?.name} userEmail={data.user?.email} />
<PageContainer theme={data.user?.dashboardTheme} themeColor="hsl(var(--primary))">
<DashboardHeader
userName={data.user?.name}
userEmail={data.user?.email}
dashboardTheme={data.user?.dashboardTheme || 'none'}
/>
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
<LocalWishlistsSection isAuthenticated={data.isAuthenticated} />

View File

@@ -35,7 +35,7 @@
);
</script>
<PageContainer maxWidth="4xl">
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
<Navigation
isAuthenticated={data.isAuthenticated}
showDashboardLink={true}
@@ -110,7 +110,7 @@
<div class="space-y-4">
{#if filteredItems.length > 0}
{#each filteredItems as item}
<WishlistItem {item}>
<WishlistItem {item} theme={data.wishlist.theme}>
<ReservationButton
itemId={item.id}
isReserved={item.isReserved}

View File

@@ -203,6 +203,7 @@ export const actions: Actions = {
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)
@@ -237,6 +238,10 @@ export const actions: Actions = {
updates.endDate = endDateStr ? new Date(endDateStr) : null;
}
if (theme !== null) {
updates.theme = theme?.toString().trim() || 'none';
}
await db.update(wishlists)
.set(updates)
.where(eq(wishlists.id, wishlist.id));

View File

@@ -107,7 +107,7 @@
}
</script>
<PageContainer maxWidth="4xl">
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
<Navigation
isAuthenticated={data.isAuthenticated}
showDashboardLink={true}
@@ -119,6 +119,7 @@
onDescriptionUpdate={wishlistUpdates.updateDescription}
onColorUpdate={wishlistUpdates.updateColor}
onEndDateUpdate={wishlistUpdates.updateEndDate}
onThemeUpdate={wishlistUpdates.updateTheme}
/>
<ShareLinks
@@ -168,6 +169,7 @@
{rearranging}
onStartEditing={startEditing}
onReorder={handleReorder}
theme={data.wishlist.theme}
/>
<DangerZone bind:unlocked={rearranging} />