Compare commits

..

14 Commits

36 changed files with 683 additions and 386 deletions

3
.gitignore vendored
View File

@@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
# Claude # Claude
.claude .claude
# Infisical
.infisical*

View File

@@ -1,84 +0,0 @@
# Coolify Deployment
## Prerequisites
- Coolify instance
- Git repository
## Database Setup
Choose one:
**Option A: Docker Compose (Recommended)**
- Database included in `docker-compose.coolify.yml`
- Skip to step 2
**Option B: Separate PostgreSQL Resource**
1. Create PostgreSQL database in Coolify
2. Note connection details
## Deploy
### Using Docker Compose (Recommended)
1. Create application in Coolify
2. Select Git repository
3. Configure:
- Build Pack: Docker Compose
- File: `./docker-compose.coolify.yml`
4. Assign domain to `app` service only (format: `http://yourdomain.com:3000`)
5. Set environment variables:
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
- `AUTH_URL` (your domain with https)
- `POSTGRES_DATA_PATH` (optional, for custom database storage location)
- `GOOGLE_CLIENT_ID` (optional)
- `GOOGLE_CLIENT_SECRET` (optional)
6. Deploy
### Using Dockerfile
1. Create application in Coolify
2. Select Git repository
3. Configure:
- Build Pack: Docker
- Port: `3000`
4. Add domain
5. Set environment variables:
- `DATABASE_URL`
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
- `AUTH_URL` (your domain with https)
- `GOOGLE_CLIENT_ID` (optional)
- `GOOGLE_CLIENT_SECRET` (optional)
6. Deploy
## After Deployment
Run migrations:
```bash
# In Coolify terminal or SSH
docker exec -it <container-name> bun run db:push
```
## Environment Variables
Required:
- `DATABASE_URL` - Connection string
- `AUTH_SECRET` - Random secret
- `AUTH_URL` - Your app URL
- `AUTH_TRUST_HOST` - `true`
Optional:
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `POSTGRES_DATA_PATH` - Custom path for PostgreSQL data (Docker Compose only)
- Example: `/mnt/storage/wishlist/postgres`
- If not set, uses a Docker named volume
- Path must exist with proper permissions before deployment
## Troubleshooting
**Container crashes:** Check logs in Coolify dashboard
**Database connection:** Verify `DATABASE_URL` format
**Auth issues:** Check `AUTH_URL` matches your domain

35
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,35 @@
# Wishlist Production Deployment
Uses Phase for secrets management.
## Required Secrets
Ensure these exist in your Phase project under the `Production` environment:
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - Database credentials
- `AUTH_SECRET` - Auth.js secret (generate: `openssl rand -base64 32`)
- `AUTH_URL` - Public URL (e.g., `https://wish.rasmusq.com`)
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` - (Optional) Google OAuth
- `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER` - (Optional) Authentik OAuth
## Common Commands
```bash
# Deploy/rebuild
phase run docker compose -f docker-compose.prod.yml up -d --build
# View logs
docker compose -f docker-compose.prod.yml logs -f
# Restart
phase run docker compose -f docker-compose.prod.yml restart
# Stop
phase run docker compose -f docker-compose.prod.yml down
# Database migrations
phase run bun run db:migrate
# Development
phase run bun run dev
```

View File

@@ -2,6 +2,9 @@
A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL. A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL.
![Dashboard](readme-assets/dashboard.png)
![Creating a wishlist](readme-assets/create.png)
## Prerequisites ## Prerequisites
- [Bun](https://bun.sh/) - [Bun](https://bun.sh/)

View File

@@ -1,51 +0,0 @@
# Coolify-optimized Docker Compose
# Includes both app and database - database is only exposed internally
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-wishlistuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wishlistpassword}
POSTGRES_DB: ${POSTGRES_DB:-wishlist}
volumes:
- type: bind
source: ${POSTGRES_DATA_PATH}
target: /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wishlistuser} -d ${POSTGRES_DB:-wishlist}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# NOTE: No ports exposed - only accessible internally by app service
app:
build:
context: .
dockerfile: Dockerfile
environment:
# Coolify will inject these from Environment Variables
DATABASE_URL: postgresql://${POSTGRES_USER:-wishlistuser}:${POSTGRES_PASSWORD:-wishlistpassword}@db:5432/${POSTGRES_DB:-wishlist}
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL:-https://wish.rasmusq.com}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
- traefik.http.routers.wishlist.entryPoints=https
- traefik.http.routers.wishlist.tls=true
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
- traefik.http.services.wishlist.loadbalancer.server.port=3000
# Forward headers for Auth.js behind reverse proxy
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Proto=https
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Host=wish.rasmusq.com
- traefik.http.routers.wishlist.middlewares=wishlist-headers

21
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
database:
image: postgres:16-alpine
container_name: wishlist-postgres-test
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:

62
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,62 @@
services:
database:
image: postgres:16-alpine
container_name: wishlist-postgres
restart: unless-stopped
pull_policy: always
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wishlist-net
app:
build:
context: .
dockerfile: Dockerfile
container_name: wishlist-app
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL}
AUTH_TRUST_HOST: "true"
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
depends_on:
database:
condition: service_healthy
networks:
- wishlist-net
- traefik-net
labels:
- traefik.enable=true
- traefik.docker.network=traefik-net
# HTTPS router
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
- traefik.http.routers.wishlist.entrypoints=websecure
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
# Forward headers for Auth.js
- traefik.http.routers.wishlist.middlewares=wishlist-headers
- traefik.http.middlewares.wishlist-headers.headers.customRequestHeaders.X-Forwarded-Proto=https
- traefik.http.middlewares.wishlist-headers.headers.customRequestHeaders.X-Forwarded-Host=wish.rasmusq.com
- traefik.http.services.wishlist.loadbalancer.server.port=3000
networks:
wishlist-net:
name: wishlist-net
traefik-net:
external: true

View File

@@ -1,41 +0,0 @@
services:
db:
image: postgres:16-alpine
container_name: wishlist-db
environment:
POSTGRES_USER: wishlistuser
POSTGRES_PASSWORD: wishlistpassword
POSTGRES_DB: wishlist
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wishlistuser -d wishlist"]
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: wishlist-app
environment:
DATABASE_URL: postgresql://wishlistuser:wishlistpassword@db:5432/wishlist
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
AUTH_URL: ${AUTH_URL:-http://localhost:3000}
AUTH_TRUST_HOST: true
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:

BIN
readme-assets/create.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

BIN
readme-assets/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

View File

@@ -125,7 +125,7 @@ const authConfig: SvelteKitAuthConfig = {
} }
}, },
secret: env.AUTH_SECRET, secret: env.AUTH_SECRET,
trustHost: true trustHost: env.AUTH_TRUST_HOST === 'true'
}; };
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig); export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);

View File

@@ -21,11 +21,9 @@
let localWishlists = $state<LocalWishlist[]>([]); let localWishlists = $state<LocalWishlist[]>([]);
let enrichedWishlists = $state<any[]>([]); let enrichedWishlists = $state<any[]>([]);
// Load local wishlists on mount and fetch their data from server
onMount(async () => { onMount(async () => {
localWishlists = getLocalWishlists(); localWishlists = getLocalWishlists();
// Fetch full wishlist data for each local wishlist
const promises = localWishlists.map(async (local) => { const promises = localWishlists.map(async (local) => {
try { try {
const response = await fetch(`/api/wishlist/${local.ownerToken}`); const response = await fetch(`/api/wishlist/${local.ownerToken}`);
@@ -39,7 +37,6 @@
} catch (error) { } catch (error) {
console.error('Failed to fetch wishlist data:', error); console.error('Failed to fetch wishlist data:', error);
} }
// Fallback to local data if fetch fails
return { return {
id: local.ownerToken, id: local.ownerToken,
title: local.title, title: local.title,

View File

@@ -80,8 +80,8 @@
</div> </div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0"> <div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" /> <ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} /> <ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
<LanguageToggle /> <LanguageToggle color={localColor} />
<ThemeToggle /> <ThemeToggle />
{#if isAuthenticated} {#if isAuthenticated}
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button> <Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>

View File

@@ -7,10 +7,12 @@
let { let {
isAuthenticated = false, isAuthenticated = false,
showDashboardLink = false showDashboardLink = false,
color = null
}: { }: {
isAuthenticated?: boolean; isAuthenticated?: boolean;
showDashboardLink?: boolean; showDashboardLink?: boolean;
color?: string | null;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
@@ -28,7 +30,7 @@
</Button> </Button>
{/if} {/if}
<div class="ml-auto flex items-center gap-1 sm:gap-2"> <div class="ml-auto flex items-center gap-1 sm:gap-2">
<LanguageToggle /> <LanguageToggle {color} />
<ThemeToggle /> <ThemeToggle size="sm" {color} />
</div> </div>
</nav> </nav>

View File

@@ -19,7 +19,6 @@
class="fixed top-0 right-0 left-0 pointer-events-none z-0" class="fixed top-0 right-0 left-0 pointer-events-none z-0"
style=" style="
mask-image: url({patternPath}); mask-image: url({patternPath});
-webkit-mask-image: url({patternPath});
mask-size: cover; mask-size: cover;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: right top; mask-position: right top;

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { X, Pencil } from 'lucide-svelte'; import { X, Pencil } from 'lucide-svelte';
import IconButton from './IconButton.svelte';
let { let {
color = $bindable(null), color = $bindable(null),
@@ -39,17 +40,18 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if color} {#if color}
<button <IconButton
type="button"
onclick={clearColor} onclick={clearColor}
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors" {color}
{size}
aria-label="Clear color" aria-label="Clear color"
rounded="md"
> >
<X class={iconSize} /> <X class={iconSize} />
</button> </IconButton>
{/if} {/if}
<label <label
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden" 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};` : ''} 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));' : ''} /> <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));' : ''} />

View File

@@ -0,0 +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';
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);
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);`
: ''
);
function getItemStyle(itemValue: string): string {
if (!color) return '';
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
}
function toggleMenu() {
showMenu = !showMenu;
}
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 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';
}
}
$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>
{#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;
}
</style>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
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;
}
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 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];
</script>
<button
type="button"
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
class:hover:bg-accent={!color}
style={color ? `--hover-bg: ${color}20;` : ''}
{...restProps}
>
{@render children()}
</button>
<style>
button[style*='--hover-bg']:hover {
background-color: var(--hover-bg);
}
</style>

View File

@@ -1,58 +1,32 @@
<script lang="ts"> <script lang="ts">
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { languages } from '$lib/i18n/translations'; import { languages } from '$lib/i18n/translations';
import { Button } from '$lib/components/ui/button'; import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Languages } from 'lucide-svelte'; import { Languages } from 'lucide-svelte';
let showMenu = $state(false); let { color }: { color?: string | null } = $props();
function toggleMenu() { const languageItems = $derived(
showMenu = !showMenu; languages.map((lang) => ({
value: lang.code,
label: lang.name
}))
);
function setLanguage(code: string) {
languageStore.setLanguage(code as 'en' | 'da');
} }
function setLanguage(code: 'en' | 'da') {
languageStore.setLanguage(code);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.language-toggle-menu')) {
showMenu = false;
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script> </script>
<div class="relative language-toggle-menu"> <Dropdown
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="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]" /> <Languages class="h-[1.2rem] w-[1.2rem]" />
</Button> {/snippet}
</Dropdown>
{#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 languages as lang}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
class:font-bold={languageStore.current === lang.code}
class:bg-slate-100={languageStore.current === lang.code}
class:dark:bg-slate-900={languageStore.current === lang.code}
onclick={() => setLanguage(lang.code)}
>
{lang.name}
</button>
{/each}
</div>
</div>
{/if}
</div>

View File

@@ -1,68 +1,35 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Palette } from 'lucide-svelte'; import { Palette } from 'lucide-svelte';
import { AVAILABLE_THEMES } from '$lib/utils/themes'; import { AVAILABLE_THEMES } from '$lib/utils/themes';
let { let {
value = 'none', value = 'none',
onValueChange onValueChange,
color
}: { }: {
value?: string; value?: string;
onValueChange: (theme: string) => void; onValueChange: (theme: string) => void;
color?: string | null;
} = $props(); } = $props();
let showMenu = $state(false); const themeItems = $derived(
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
function toggleMenu() { value: key,
showMenu = !showMenu; label: theme.name
} }))
);
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> </script>
<div class="relative theme-picker-menu"> <Dropdown
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="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]" /> <Palette class="h-[1.2rem] w-[1.2rem]" />
</Button> {/snippet}
</Dropdown>
{#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

@@ -1,14 +1,22 @@
<script lang="ts"> <script lang="ts">
import { themeStore } from '$lib/stores/theme.svelte'; import { themeStore } from '$lib/stores/theme.svelte';
import { Button } from '$lib/components/ui/button';
import { Sun, Moon, Monitor } from 'lucide-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();
function toggle() { function toggle() {
themeStore.toggle(); themeStore.toggle();
} }
</script> </script>
<Button onclick={toggle} variant="ghost" size="icon" class="rounded-full"> <IconButton onclick={toggle} {size} {color} rounded="md">
{#if themeStore.current === 'light'} {#if themeStore.current === 'light'}
<Sun size={20} /> <Sun size={20} />
<span class="sr-only">Light mode (click for dark)</span> <span class="sr-only">Light mode (click for dark)</span>
@@ -19,4 +27,4 @@
<Monitor size={20} /> <Monitor size={20} />
<span class="sr-only">System mode (click for light)</span> <span class="sr-only">System mode (click for light)</span>
{/if} {/if}
</Button> </IconButton>

View File

@@ -72,7 +72,7 @@
</div> </div>
{:else} {:else}
<Card style={cardStyle} class="relative overflow-hidden"> <Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={wishlistColor} /> <ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
<CardContent class="p-12 relative z-10"> <CardContent class="p-12 relative z-10">
<EmptyState <EmptyState
message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"} message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"}

View File

@@ -6,6 +6,7 @@
import { Pencil, Check, X } from "lucide-svelte"; import { Pencil, Check, X } from "lucide-svelte";
import ColorPicker from "$lib/components/ui/ColorPicker.svelte"; import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
import ThemePicker from "$lib/components/ui/theme-picker.svelte"; import ThemePicker from "$lib/components/ui/theme-picker.svelte";
import IconButton from "$lib/components/ui/IconButton.svelte";
import type { Wishlist } from "$lib/server/schema"; import type { Wishlist } from "$lib/server/schema";
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { getCardStyle } from '$lib/utils/colors'; import { getCardStyle } from '$lib/utils/colors';
@@ -80,7 +81,6 @@
} }
</script> </script>
<!-- Title Header -->
<div class="flex items-center justify-between gap-4 mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-2 flex-1 min-w-0"> <div class="flex items-center gap-2 flex-1 min-w-0">
{#if editingTitle} {#if editingTitle}
@@ -100,8 +100,7 @@
{:else} {:else}
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1> <h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
{/if} {/if}
<button <IconButton
type="button"
onclick={() => { onclick={() => {
if (editingTitle) { if (editingTitle) {
saveTitle(); saveTitle();
@@ -109,7 +108,9 @@
editingTitle = true; editingTitle = true;
} }
}} }}
class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors" color={wishlistColor}
size="sm"
class="shrink-0"
aria-label={editingTitle ? "Save title" : "Edit title"} aria-label={editingTitle ? "Save title" : "Edit title"}
> >
{#if editingTitle} {#if editingTitle}
@@ -117,7 +118,7 @@
{:else} {:else}
<Pencil class="w-4 h-4" /> <Pencil class="w-4 h-4" />
{/if} {/if}
</button> </IconButton>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<ThemePicker <ThemePicker
@@ -128,6 +129,7 @@
// Force reactivity by updating the wishlist object // Force reactivity by updating the wishlist object
wishlist.theme = theme; wishlist.theme = theme;
}} }}
color={wishlistColor}
/> />
<ColorPicker <ColorPicker
bind:color={wishlistColor} bind:color={wishlistColor}
@@ -137,15 +139,12 @@
</div> </div>
</div> </div>
<!-- Settings Card -->
<Card style={cardStyle}> <Card style={cardStyle}>
<CardContent class="pt-6 space-y-4"> <CardContent class="pt-6 space-y-4">
<!-- Description -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label for="wishlist-description">{t.form.descriptionOptional}</Label> <Label for="wishlist-description">{t.form.descriptionOptional}</Label>
<button <IconButton
type="button"
onclick={() => { onclick={() => {
if (editingDescription) { if (editingDescription) {
saveDescription(); saveDescription();
@@ -153,7 +152,9 @@
editingDescription = true; editingDescription = true;
} }
}} }}
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors" color={wishlistColor}
size="sm"
class="flex-shrink-0"
aria-label={editingDescription ? "Save description" : "Edit description"} aria-label={editingDescription ? "Save description" : "Edit description"}
> >
{#if editingDescription} {#if editingDescription}
@@ -161,7 +162,7 @@
{:else} {:else}
<Pencil class="w-4 h-4" /> <Pencil class="w-4 h-4" />
{/if} {/if}
</button> </IconButton>
</div> </div>
{#if editingDescription} {#if editingDescription}
<Textarea <Textarea
@@ -184,19 +185,19 @@
{/if} {/if}
</div> </div>
<!-- End Date -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4"> <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> <Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if wishlistEndDate} {#if wishlistEndDate}
<button <IconButton
type="button"
onclick={clearEndDate} onclick={clearEndDate}
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors" color={wishlistColor}
size="sm"
class="flex-shrink-0"
aria-label="Clear end date" aria-label="Clear end date"
> >
<X class="w-4 h-4" /> <X class="w-4 h-4" />
</button> </IconButton>
{/if} {/if}
<Input <Input
id="wishlist-end-date" id="wishlist-end-date"

View File

@@ -57,7 +57,7 @@
</script> </script>
<Card style={cardStyle} class="relative overflow-hidden"> <Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={item.color} /> <ThemeCard themeName={theme} color={item.color} showPattern={false} />
<CardContent class="p-6 relative z-10"> <CardContent class="p-6 relative z-10">
<div class="flex gap-4"> <div class="flex gap-4">
{#if showDragHandle} {#if showDragHandle}

View File

@@ -11,7 +11,7 @@ export const users = pgTable('user', {
email: text('email').unique(), email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }), emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'), image: text('image'),
password: text('password'), password: text('password').notNull(),
username: text('username').unique(), username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'), dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'), dashboardColor: text('dashboard_color'),

View File

@@ -0,0 +1,86 @@
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();
}
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;
}
export function sanitizeColor(color: string | null | undefined): string | null {
if (!color) {
return null;
}
return color.trim();
}
export function sanitizeCurrency(currency: string | null | undefined): string {
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;
}
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;
}
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;
}

View File

@@ -36,7 +36,6 @@ export function addLocalWishlist(wishlist: LocalWishlist): void {
try { try {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
// Check if already exists
const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken); const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken);
if (exists) return; if (exists) return;

View File

@@ -1,5 +1,21 @@
import type { RequestHandler } from './$types'; 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;
}
}
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const imageUrl = url.searchParams.get('url'); const imageUrl = url.searchParams.get('url');
@@ -7,6 +23,10 @@ export const GET: RequestHandler = async ({ url }) => {
return new Response('Image URL is required', { status: 400 }); return new Response('Image URL is required', { status: 400 });
} }
if (!isValidImageUrl(imageUrl)) {
return new Response('Invalid image URL', { status: 400 });
}
try { try {
// Fetch the image with proper headers to avoid blocking // Fetch the image with proper headers to avoid blocking
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {

View File

@@ -1,6 +1,22 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; 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;
}
}
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json(); const { url } = await request.json();
@@ -8,6 +24,10 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ error: 'URL is required' }, { status: 400 }); return json({ error: 'URL is required' }, { status: 400 });
} }
if (!isValidUrl(url)) {
return json({ error: 'Invalid URL' }, { status: 400 });
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
@@ -15,7 +35,9 @@ export const POST: RequestHandler = async ({ request }) => {
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', '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': '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-Language': 'en-US,en;q=0.9',
'Referer': 'https://www.google.com/' 'Accept-Encoding': 'gzip, deflate, br',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
} }
}); });
@@ -28,13 +50,6 @@ export const POST: RequestHandler = async ({ request }) => {
const origin = baseUrl.origin; const origin = baseUrl.origin;
const imageUrls: string[] = []; const imageUrls: string[] = [];
// Match various image source patterns
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
const twitterImageRegex = /<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
const jsonLdRegex = /"image"\s*:\s*"([^"]+)"/gi;
function toAbsoluteUrl(imgUrl: string): string { function toAbsoluteUrl(imgUrl: string): string {
if (imgUrl.startsWith('http')) { if (imgUrl.startsWith('http')) {
@@ -49,72 +64,157 @@ export const POST: RequestHandler = async ({ request }) => {
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;
}
let match; let match;
// Priority 1: OpenGraph and Twitter meta tags (usually the best product images) // 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) { while ((match = ogImageRegex.exec(html)) !== null) {
imageUrls.push(toAbsoluteUrl(match[1])); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
} }
while ((match = twitterImageRegex.exec(html)) !== null) { while ((match = twitterImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (!imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 2: JSON-LD structured data (common for e-commerce) // 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) { while ((match = jsonLdRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); try {
if (!imageUrls.includes(url)) { const jsonStr = match[1];
imageUrls.push(url); 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);
}
}
}
}
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: data-src attributes (lazy loaded images) // Priority 3: Look for data-image attributes (common in React/SPA)
while ((match = dataSrcRegex.exec(html)) !== null) { const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
while ((match = dataImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (!imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 4: srcset attributes (responsive images) // Priority 4: srcset attributes (responsive images)
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
while ((match = srcsetRegex.exec(html)) !== null) { while ((match = srcsetRegex.exec(html)) !== null) {
const srcsetValue = match[1]; const srcsetValue = match[1];
// srcset can have multiple URLs with sizes, extract them
const srcsetUrls = srcsetValue.split(',').map((s) => { const srcsetUrls = srcsetValue.split(',').map((s) => {
const parts = s.trim().split(/\s+/); const parts = s.trim().split(/\s+/);
return parts[0]; // Get the URL part before size descriptor return parts[0];
}); });
for (const srcsetUrl of srcsetUrls) { for (const srcsetUrl of srcsetUrls) {
const url = toAbsoluteUrl(srcsetUrl); const url = toAbsoluteUrl(srcsetUrl);
if (!imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
} }
// Priority 5: Regular img src attributes // Priority 5: data-src attributes (lazy loaded)
while ((match = imgRegex.exec(html)) !== null) { const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
while ((match = dataSrcRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (!imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
const filteredImages = imageUrls.filter( // Priority 6: Regular img src attributes
(url) => const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
!url.toLowerCase().includes('logo') && while ((match = imgRegex.exec(html)) !== null) {
!url.toLowerCase().includes('icon') && const url = toAbsoluteUrl(match[1]);
!url.toLowerCase().includes('sprite') && if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
!url.toLowerCase().includes('favicon') && imageUrls.push(url);
!url.endsWith('.svg') && }
url.length < 1000 && // Increased limit for modern CDN URLs }
!url.includes('data:image') // Skip data URLs
);
return json({ images: filteredImages.slice(0, 30) }); // 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);
});
return json({ images: finalImages.slice(0, 30) });
} catch (error) { } catch (error) {
return json({ error: 'Failed to scrape images' }, { status: 500 }); return json({ error: 'Failed to scrape images' }, { status: 500 });
} }

View File

@@ -3,11 +3,24 @@ import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists } from '$lib/server/schema'; import { wishlists } from '$lib/server/schema';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { sanitizeString, sanitizeColor } from '$lib/server/validation';
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const { title, description, color } = await request.json(); const body = await request.json();
if (!title?.trim()) { 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 });
}
if (!title) {
return json({ error: 'Title is required' }, { status: 400 }); return json({ error: 'Title is required' }, { status: 400 });
} }
@@ -20,9 +33,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const [wishlist] = await db const [wishlist] = await db
.insert(wishlists) .insert(wishlists)
.values({ .values({
title: title.trim(), title,
description: description?.trim() || null, description,
color: color?.trim() || null, color,
ownerToken, ownerToken,
publicToken, publicToken,
userId userId

View File

@@ -147,7 +147,6 @@ export const actions: Actions = {
return { success: false, error: 'Wishlist ID is required' }; return { success: false, error: 'Wishlist ID is required' };
} }
// Verify the user owns this wishlist
await db.delete(wishlists) await db.delete(wishlists)
.where(and( .where(and(
eq(wishlists.id, wishlistId), eq(wishlists.id, wishlistId),

View File

@@ -11,12 +11,10 @@
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
// For anonymous users, get theme from localStorage
function getInitialTheme() { function getInitialTheme() {
if (data.isAuthenticated) { if (data.isAuthenticated) {
return data.user?.dashboardTheme || 'none'; return data.user?.dashboardTheme || 'none';
} else { } else {
// Anonymous user - get from localStorage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardTheme') || 'none'; return localStorage.getItem('dashboardTheme') || 'none';
} }
@@ -24,12 +22,10 @@
} }
} }
// For anonymous users, get color from localStorage
function getInitialColor() { function getInitialColor() {
if (data.isAuthenticated) { if (data.isAuthenticated) {
return data.user?.dashboardColor || null; return data.user?.dashboardColor || null;
} else { } else {
// Anonymous user - get from localStorage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardColor') || null; return localStorage.getItem('dashboardColor') || null;
} }
@@ -40,7 +36,6 @@
let currentTheme = $state(getInitialTheme()); let currentTheme = $state(getInitialTheme());
let currentColor = $state(getInitialColor()); let currentColor = $state(getInitialColor());
// Save to localStorage when theme changes for anonymous users
function handleThemeUpdate(theme: string | null) { function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none'; currentTheme = theme || 'none';
@@ -49,7 +44,6 @@
} }
} }
// Save to localStorage when color changes for anonymous users
function handleColorUpdate(color: string | null) { function handleColorUpdate(color: string | null) {
currentColor = color; currentColor = color;
@@ -64,10 +58,8 @@
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
// Only owned wishlists for "My Wishlists"
const myWishlists = $derived(() => data.wishlists || []); const myWishlists = $derived(() => data.wishlists || []);
// Claimed wishlists (those with ownerToken, meaning they were claimed via edit link)
const claimedWishlists = $derived(() => { const claimedWishlists = $derived(() => {
return (data.savedWishlists || []) return (data.savedWishlists || [])
.filter(saved => saved.wishlist?.ownerToken) .filter(saved => saved.wishlist?.ownerToken)
@@ -79,7 +71,6 @@
})); }));
}); });
// Saved wishlists are those WITHOUT ownerToken (saved from public view only)
const savedWishlists = $derived(() => { const savedWishlists = $derived(() => {
return (data.savedWishlists || []).filter(saved => !saved.wishlist?.ownerToken); return (data.savedWishlists || []).filter(saved => !saved.wishlist?.ownerToken);
}); });
@@ -96,15 +87,7 @@
onColorUpdate={handleColorUpdate} onColorUpdate={handleColorUpdate}
/> />
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
<LocalWishlistsSection
isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
/>
{#if data.isAuthenticated} {#if data.isAuthenticated}
<!-- My Wishlists Section -->
<WishlistSection <WishlistSection
title={t.dashboard.myWishlists} title={t.dashboard.myWishlists}
description={t.dashboard.myWishlistsDescription} description={t.dashboard.myWishlistsDescription}
@@ -162,7 +145,12 @@
{/snippet} {/snippet}
</WishlistSection> </WishlistSection>
<!-- Claimed Wishlists Section --> <LocalWishlistsSection
isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
/>
<WishlistSection <WishlistSection
title={t.dashboard.claimedWishlists} title={t.dashboard.claimedWishlists}
description={t.dashboard.claimedWishlistsDescription} description={t.dashboard.claimedWishlistsDescription}
@@ -219,7 +207,6 @@
{/snippet} {/snippet}
</WishlistSection> </WishlistSection>
<!-- Saved Wishlists Section -->
<WishlistSection <WishlistSection
title={t.dashboard.savedWishlists} title={t.dashboard.savedWishlists}
description={t.dashboard.savedWishlistsDescription} description={t.dashboard.savedWishlistsDescription}
@@ -228,6 +215,7 @@
emptyDescription={t.dashboard.emptySavedWishlistsDescription} emptyDescription={t.dashboard.emptySavedWishlistsDescription}
fallbackColor={currentColor} fallbackColor={currentColor}
fallbackTheme={currentTheme} fallbackTheme={currentTheme}
hideIfEmpty={true}
> >
{#snippet actions(saved, unlocked)} {#snippet actions(saved, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">

View File

@@ -5,6 +5,7 @@ import { users } from '$lib/server/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { sanitizeString, sanitizeUsername } from '$lib/server/validation';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
// Determine which OAuth providers are available // Determine which OAuth providers are available
@@ -31,12 +32,18 @@ export const actions: Actions = {
const password = formData.get('password') as string; const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string; const confirmPassword = formData.get('confirmPassword') as string;
if (!name?.trim()) { let sanitizedUsername: string;
return fail(400, { error: 'Name is required', name, username }); let sanitizedName: string | null;
try {
sanitizedName = sanitizeString(name, 100);
sanitizedUsername = sanitizeUsername(username);
} catch (error) {
return fail(400, { error: 'Invalid input', name, username });
} }
if (!username?.trim()) { if (!sanitizedName) {
return fail(400, { error: 'Username is required', name, username }); return fail(400, { error: 'Name is required', name, username });
} }
if (!password || password.length < 8) { if (!password || password.length < 8) {
@@ -48,18 +55,18 @@ export const actions: Actions = {
} }
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.username, username.trim().toLowerCase()) where: eq(users.username, sanitizedUsername)
}); });
if (existingUser) { if (existingUser) {
return fail(400, { error: 'Username already taken', name, username }); return fail(400, { error: 'Username already taken', name, username });
} }
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 14);
await db.insert(users).values({ await db.insert(users).values({
name: name.trim(), name: sanitizedName,
username: username.trim().toLowerCase(), username: sanitizedUsername,
password: hashedPassword password: hashedPassword
}); });

View File

@@ -34,7 +34,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
) )
}); });
isSaved = !!saved; isSaved = !!saved;
isClaimed = !!saved?.ownerToken; // User has claimed if ownerToken exists in savedWishlists isClaimed = !!saved?.ownerToken;
savedWishlistId = saved?.id || null; savedWishlistId = saved?.id || null;
} }

View File

@@ -38,6 +38,7 @@
<Navigation <Navigation
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
showDashboardLink={true} showDashboardLink={true}
color={data.wishlist.color}
/> />
<Card style={headerCardStyle}> <Card style={headerCardStyle}>
@@ -124,7 +125,7 @@
</Card> </Card>
{:else} {:else}
<Card style={headerCardStyle} class="relative overflow-hidden"> <Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} /> <ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} showPattern={false} />
<CardContent class="p-12 relative z-10"> <CardContent class="p-12 relative z-10">
<EmptyState <EmptyState
message={t.wishlist.emptyWishes} message={t.wishlist.emptyWishes}

View File

@@ -123,6 +123,7 @@
<Navigation <Navigation
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
showDashboardLink={true} showDashboardLink={true}
color={currentColor}
/> />
<WishlistHeader <WishlistHeader