Compare commits
14 Commits
feature/da
...
686b43bc18
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
686b43bc18 | ||
|
|
83d68b0c58 | ||
|
|
988c7ef6b5 | ||
|
|
9f8ae9a972 | ||
| d046c66bc7 | |||
| 0b1e2b8dd3 | |||
|
|
7453c356bb | ||
|
|
a5e47cdc1f | ||
|
|
b3f123388f | ||
|
|
8f574380ce | ||
|
|
cb4539a982 | ||
|
|
466704a23a | ||
|
|
b381a6d669 | ||
| 2b12896374 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,3 +24,6 @@ vite.config.ts.timestamp-*
|
|||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
# Infisical
|
||||||
|
.infisical*
|
||||||
|
|||||||
@@ -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
35
DEPLOYMENT.md
Normal 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
|
||||||
|
```
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL.
|
A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- [Bun](https://bun.sh/)
|
- [Bun](https://bun.sh/)
|
||||||
|
|||||||
@@ -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
21
docker-compose.dev.yml
Normal 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
62
docker-compose.prod.yml
Normal 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
|
||||||
@@ -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
BIN
readme-assets/create.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
BIN
readme-assets/dashboard.png
Normal file
BIN
readme-assets/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 708 KiB |
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));' : ''} />
|
||||||
|
|||||||
133
src/lib/components/ui/Dropdown.svelte
Normal file
133
src/lib/components/ui/Dropdown.svelte
Normal 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>
|
||||||
52
src/lib/components/ui/IconButton.svelte
Normal file
52
src/lib/components/ui/IconButton.svelte
Normal 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>
|
||||||
@@ -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: 'en' | 'da') {
|
function setLanguage(code: string) {
|
||||||
languageStore.setLanguage(code);
|
languageStore.setLanguage(code as 'en' | 'da');
|
||||||
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>
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 + "!"}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
86
src/lib/server/validation.ts
Normal file
86
src/lib/server/validation.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: data-src attributes (lazy loaded images)
|
const jsonImages = new Set<string>();
|
||||||
while ((match = dataSrcRegex.exec(html)) !== null) {
|
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]);
|
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)
|
||||||
|
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) {
|
while ((match = imgRegex.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 7: Background images in style attributes (common in some e-commerce)
|
||||||
(url) =>
|
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi;
|
||||||
!url.toLowerCase().includes('logo') &&
|
while ((match = bgImageRegex.exec(html)) !== null) {
|
||||||
!url.toLowerCase().includes('icon') &&
|
const url = toAbsoluteUrl(match[1]);
|
||||||
!url.toLowerCase().includes('sprite') &&
|
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) {
|
||||||
!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) });
|
// 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@
|
|||||||
<Navigation
|
<Navigation
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
showDashboardLink={true}
|
showDashboardLink={true}
|
||||||
|
color={currentColor}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WishlistHeader
|
<WishlistHeader
|
||||||
|
|||||||
Reference in New Issue
Block a user