Compare commits

24 Commits
Author SHA1 Message Date
Rasmus Q 686b43bc18 update: refactor deployment docs to use Phase instead of Infisical 2026-03-15 20:43:01 +00:00
Rasmus Q 83d68b0c58 fix: strengthen password security - increase bcrypt rounds to 14, add notNull constraint to password field, add pull_policy to docker-compose 2026-03-15 20:33:12 +00:00
Rasmus Krogh Udengaard 988c7ef6b5 update: better image scraping 2026-03-05 15:19:19 +01:00
Rasmus Krogh Udengaard 9f8ae9a972 add: simple validation and sanitizing 2026-03-05 15:04:12 +01:00
rasmusq d046c66bc7 update: hide empty claimed wishlists, move local wishlists, cleanup comments 2025-12-21 17:37:54 +01:00
rasmusq 0b1e2b8dd3 update: generalize docker deployment 2025-12-21 16:14:46 +01:00
Rasmus Q 7453c356bb update: wishlist docker compose file 2025-12-19 23:02:34 +00:00
rasmusq a5e47cdc1f update: readme with images 2025-12-20 00:02:10 +01:00
rasmusq b3f123388f update: more visual consistency 2025-12-19 23:48:23 +01:00
rasmusq 8f574380ce update: theme switcher button use new generalized button 2025-12-19 23:35:43 +01:00
rasmusq cb4539a982 update: generalize button components 2025-12-19 23:23:46 +01:00
rasmusq 466704a23a fix: dropdown theme colors 2025-12-19 23:13:03 +01:00
rasmusq b381a6d669 update: reduce theme pattern prescence 2025-12-19 22:53:41 +01:00
rasmusq 2b12896374 Merge pull request 'feature/database-timestamps' (#7) from feature/database-timestamps into master
Reviewed-on: #7
2025-12-19 21:34:52 +00:00
rasmusq bdfcdcc15b add: snow theme 2025-12-19 22:34:23 +01:00
rasmusq b848477729 update: consistent round button sizing 2025-12-19 21:01:17 +01:00
rasmusq ed9da14fa5 add: create, update and login dates in database 2025-12-19 20:50:06 +01:00
rasmusq 23ff65f3e7 Merge pull request 'feature/userdefined-dashboard-color' (#6) from feature/userdefined-dashboard-color into master
Reviewed-on: #6
2025-12-19 19:22:35 +00:00
rasmusq ac81b8175c fix: missing color on empty lists in edit and wishlist pages 2025-12-19 20:20:11 +01:00
rasmusq 19493b4cd3 remove: svg from dashboard lists 2025-12-19 20:09:49 +01:00
rasmusq b80ef2cfea add: color selection on dashboard 2025-12-19 19:33:43 +01:00
rasmusq 22f9f8f0c9 Merge pull request 'update: better color support on wishlists' (#5) from update/wishlist-color-update-updates-background-and-svg-color into master
Reviewed-on: #5
2025-12-16 13:43:49 +00:00
rasmusq 3af098505b update: better color support on wishlists 2025-12-16 14:42:26 +01:00
rasmusq a3c4067a4c Merge pull request 'feature/builtin-themes' (#4) from feature/builtin-themes into master
Reviewed-on: #4
2025-12-14 19:49:13 +00:00
104 changed files with 5719 additions and 9297 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"version": "2",
"phaseApp": "wishlist",
"appId": "a4d85c7a-8df9-462b-9b91-5cb2957cdcd3",
"defaultEnv": "Production",
"envId": "496d0105-f2b4-424d-a1a1-a60602fc2252",
"monorepoSupport": false
}
-15
View File
@@ -1,15 +0,0 @@
# Ignore files for Prettier
build
.svelte-kit
dist
node_modules
# Ignore generated files
drizzle/meta
# Ignore lock files
bun.lock
package-lock.json
yarn.lock
pnpm-lock.yaml
-9
View File
@@ -1,9 +0,0 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
-3
View File
@@ -35,13 +35,11 @@ docker run -d \
## Environment Variables ## Environment Variables
Required: Required:
- `DATABASE_URL` - PostgreSQL connection string - `DATABASE_URL` - PostgreSQL connection string
- `NODE_ENV` - Set to `production` - `NODE_ENV` - Set to `production`
- `PORT` - Default `3000` - `PORT` - Default `3000`
Optional (Docker Compose): Optional (Docker Compose):
- `POSTGRES_USER` - Database user (default: `wishlistuser`) - `POSTGRES_USER` - Database user (default: `wishlistuser`)
- `POSTGRES_PASSWORD` - Database password (default: `wishlistpassword`) - `POSTGRES_PASSWORD` - Database password (default: `wishlistpassword`)
- `POSTGRES_DB` - Database name (default: `wishlist`) - `POSTGRES_DB` - Database name (default: `wishlist`)
@@ -63,7 +61,6 @@ docker exec -it wishlist-app bun run db:push
## Migrations ## Migrations
Production migrations: Production migrations:
```bash ```bash
docker exec -it wishlist-app bun run db:migrate docker exec -it wishlist-app bun run db:migrate
``` ```
+8 -11
View File
@@ -1,16 +1,13 @@
# Use Node.js official image # Use Bun's official image
FROM node:20-alpine AS base FROM oven/bun:1 AS base
WORKDIR /app WORKDIR /app
# Install pnpm
RUN npm install -g pnpm
# Install dependencies # Install dependencies
FROM base AS deps FROM base AS deps
COPY package.json pnpm-lock.yaml* ./ COPY package.json bun.lock* ./
COPY patches ./patches COPY patches ./patches
RUN pnpm install --frozen-lockfile RUN bun install --frozen-lockfile
RUN pnpm patch-package RUN bunx patch-package
# Build the application # Build the application
FROM base AS builder FROM base AS builder
@@ -18,7 +15,7 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
# Build the SvelteKit app # Build the SvelteKit app
RUN pnpm run build RUN bun run build
# Production image # Production image
FROM base AS runner FROM base AS runner
@@ -35,10 +32,10 @@ COPY --from=deps /app/node_modules ./node_modules
# Copy Drizzle files for migrations # Copy Drizzle files for migrations
COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder /app/src/lib/db ./src/lib/db COPY --from=builder /app/src/lib/server/schema.ts ./src/lib/server/schema.ts
# Expose the port # Expose the port
EXPOSE 3000 EXPOSE 3000
# Start the application # Start the application
CMD ["node", "./build/index.js"] CMD ["bun", "run", "./build/index.js"]
-5
View File
@@ -16,7 +16,6 @@ Visit `http://localhost:3000`
Choose one: Choose one:
**Local PostgreSQL:** **Local PostgreSQL:**
```bash ```bash
sudo apt install postgresql sudo apt install postgresql
sudo -u postgres createdb wishlist sudo -u postgres createdb wishlist
@@ -27,7 +26,6 @@ GRANT ALL PRIVILEGES ON DATABASE wishlist TO wishlistuser;
``` ```
**Docker PostgreSQL:** **Docker PostgreSQL:**
```bash ```bash
docker run --name wishlist-postgres \ docker run --name wishlist-postgres \
-e POSTGRES_DB=wishlist \ -e POSTGRES_DB=wishlist \
@@ -59,18 +57,15 @@ Visit `http://localhost:5173`
## Troubleshooting ## Troubleshooting
**Connection errors:** **Connection errors:**
- Check PostgreSQL is running: `sudo systemctl status postgresql` - Check PostgreSQL is running: `sudo systemctl status postgresql`
- Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"` - Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"`
**Port in use:** **Port in use:**
```bash ```bash
bun run dev -- --port 3000 bun run dev -- --port 3000
``` ```
**Schema changes:** **Schema changes:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+632
View File
@@ -0,0 +1,632 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "wishlist-app",
"dependencies": {
"@auth/core": "^0.34.3",
"@auth/drizzle-adapter": "^1.11.1",
"@auth/sveltekit": "^1.11.1",
"@internationalized/date": "^3.10.0",
"@paralleldrive/cuid2": "^3.0.4",
"bcrypt": "^6.0.0",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"drizzle-orm": "^0.44.7",
"lucide-svelte": "^0.554.0",
"postgres": "^3.4.7",
"svelte-dnd-action": "^0.9.67",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
},
"devDependencies": {
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17",
"@types/bcrypt": "^6.0.0",
"drizzle-kit": "^0.31.7",
"patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0",
"svelte": "^5.43.8",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.2.2",
},
},
},
"packages": {
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
"@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.11.1", "", { "dependencies": { "@auth/core": "0.41.1" } }, "sha512-cQTvDZqsyF7RPhDm/B6SvqdVP9EzQhy3oM4Muu7fjjmSYFLbSR203E6dH631ZHSKDn2b4WZkfMnjPDzRsPSAeA=="],
"@auth/sveltekit": ["@auth/sveltekit@1.11.1", "", { "dependencies": { "@auth/core": "0.41.1", "set-cookie-parser": "^2.7.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.3", "@sveltejs/kit": "^1.0.0 || ^2.0.0", "nodemailer": "^7.0.7", "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-cWNfXcKrNIVtJYOY1tq7H7m03j89Wg7xrTvOJALu18fZdYulzYCPIAdTw8XSEzOp6KyhOGo7tmW7VtzRNtr/8Q=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@internationalized/date": ["@internationalized/date@3.10.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.0.4", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="],
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.7", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q=="],
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="],
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="],
"@sveltejs/kit": ["@sveltejs/kit@2.49.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="],
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"bits-ui": ["bits-ui@2.14.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devalue": ["devalue@5.5.0", "", {}, "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w=="],
"drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="],
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"esrap": ["esrap@2.1.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"lucide-svelte": ["lucide-svelte@0.554.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-LLcpHi3SuKup0nVD1kKqo8FDZnjXJp48uST26GGh8Jcyrxqk5gmgpnvKmHsHox674UL3cPS1DCul/wFL7ybGqg=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
"preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="],
"preact-render-to-string": ["preact-render-to-string@5.2.3", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA=="],
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"svelte": ["svelte@5.43.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-pHeUrp1A5S6RGaXhJB7PtYjL1VVjbVrJ2EfuAoPu9/1LeoMaJa/pcdCsCSb0gS4eUHAHnhCbUDxORZyvGK6kOQ=="],
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
"svelte-dnd-action": ["svelte-dnd-action@0.9.67", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-yEJQZ9SFy3O4mnOdtjwWyotRsWRktNf4W8k67zgiLiMtMNQnwCyJHBjkGMgZMDh8EGZ4gr88l+GebBWoHDwo+g=="],
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
"tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="],
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"@auth/drizzle-adapter/@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
"@auth/sveltekit/@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@auth/drizzle-adapter/@auth/core/jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="],
"@auth/drizzle-adapter/@auth/core/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"@auth/drizzle-adapter/@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"@auth/drizzle-adapter/@auth/core/preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"@auth/sveltekit/@auth/core/jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="],
"@auth/sveltekit/@auth/core/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
"@auth/sveltekit/@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"@auth/sveltekit/@auth/core/preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
}
}
+14 -14
View File
@@ -1,16 +1,16 @@
{ {
"$schema": "https://shadcn-svelte.com/schema.json", "$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": { "tailwind": {
"css": "src/app.css", "css": "src/app.css",
"baseColor": "slate" "baseColor": "slate"
}, },
"aliases": { "aliases": {
"components": "$lib/components", "components": "$lib/components",
"utils": "$lib/utils", "utils": "$lib/utils",
"ui": "$lib/components/ui", "ui": "$lib/components/ui",
"hooks": "$lib/hooks", "hooks": "$lib/hooks",
"lib": "$lib" "lib": "$lib"
}, },
"typescript": true, "typescript": true,
"registry": "https://shadcn-svelte.com/registry" "registry": "https://shadcn-svelte.com/registry"
} }
+1 -1
View File
@@ -12,7 +12,7 @@ services:
volumes: volumes:
- db-data:/var/lib/postgresql/data - db-data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+2 -2
View File
@@ -11,7 +11,7 @@ services:
volumes: volumes:
- /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data - /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -30,7 +30,7 @@ services:
PORT: 3000 PORT: 3000
AUTH_SECRET: ${AUTH_SECRET} AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL} AUTH_URL: ${AUTH_URL}
AUTH_TRUST_HOST: 'true' AUTH_TRUST_HOST: "true"
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
+6 -6
View File
@@ -1,10 +1,10 @@
import type { Config } from 'drizzle-kit'; import type { Config } from 'drizzle-kit';
export default { export default {
schema: './src/lib/db/schema.ts', schema: './src/lib/server/schema.ts',
out: './drizzle', out: './drizzle',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
url: process.env.DATABASE_URL || '' url: process.env.DATABASE_URL || ''
} }
} satisfies Config; } satisfies Config;
+45 -45
View File
@@ -1,58 +1,58 @@
import { relations } from 'drizzle-orm/relations'; import { relations } from "drizzle-orm/relations";
import { wishlists, items, user, savedWishlists, reservations, session, account } from './schema'; import { wishlists, items, user, savedWishlists, reservations, session, account } from "./schema";
export const itemsRelations = relations(items, ({ one, many }) => ({ export const itemsRelations = relations(items, ({one, many}) => ({
wishlist: one(wishlists, { wishlist: one(wishlists, {
fields: [items.wishlistId], fields: [items.wishlistId],
references: [wishlists.id] references: [wishlists.id]
}), }),
reservations: many(reservations) reservations: many(reservations),
})); }));
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({ export const wishlistsRelations = relations(wishlists, ({one, many}) => ({
items: many(items), items: many(items),
user: one(user, { user: one(user, {
fields: [wishlists.userId], fields: [wishlists.userId],
references: [user.id] references: [user.id]
}), }),
savedWishlists: many(savedWishlists) savedWishlists: many(savedWishlists),
})); }));
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({many}) => ({
wishlists: many(wishlists), wishlists: many(wishlists),
savedWishlists: many(savedWishlists), savedWishlists: many(savedWishlists),
sessions: many(session), sessions: many(session),
accounts: many(account) accounts: many(account),
})); }));
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({ export const savedWishlistsRelations = relations(savedWishlists, ({one}) => ({
user: one(user, { user: one(user, {
fields: [savedWishlists.userId], fields: [savedWishlists.userId],
references: [user.id] references: [user.id]
}), }),
wishlist: one(wishlists, { wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId], fields: [savedWishlists.wishlistId],
references: [wishlists.id] references: [wishlists.id]
}) }),
})); }));
export const reservationsRelations = relations(reservations, ({ one }) => ({ export const reservationsRelations = relations(reservations, ({one}) => ({
item: one(items, { item: one(items, {
fields: [reservations.itemId], fields: [reservations.itemId],
references: [items.id] references: [items.id]
}) }),
})); }));
export const sessionRelations = relations(session, ({ one }) => ({ export const sessionRelations = relations(session, ({one}) => ({
user: one(user, { user: one(user, {
fields: [session.userId], fields: [session.userId],
references: [user.id] references: [user.id]
}) }),
})); }));
export const accountRelations = relations(account, ({ one }) => ({ export const accountRelations = relations(account, ({one}) => ({
user: one(user, { user: one(user, {
fields: [account.userId], fields: [account.userId],
references: [user.id] references: [user.id]
}) }),
})); }));
+133 -177
View File
@@ -1,185 +1,141 @@
import { import { pgTable, foreignKey, text, numeric, boolean, timestamp, unique, primaryKey } from "drizzle-orm/pg-core"
pgTable, import { sql } from "drizzle-orm"
foreignKey,
text,
numeric,
boolean,
timestamp,
unique,
primaryKey
} from 'drizzle-orm/pg-core';
export const items = pgTable(
'items',
{
id: text().primaryKey().notNull(),
wishlistId: text('wishlist_id').notNull(),
title: text().notNull(),
description: text(),
link: text(),
imageUrl: text('image_url'),
price: numeric({ precision: 10, scale: 2 }),
currency: text().default('DKK'),
color: text(),
order: numeric().default('0').notNull(),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull()
},
(table) => [
foreignKey({
columns: [table.wishlistId],
foreignColumns: [wishlists.id],
name: 'items_wishlist_id_wishlists_id_fk'
}).onDelete('cascade')
]
);
export const wishlists = pgTable(
'wishlists',
{
id: text().primaryKey().notNull(),
userId: text('user_id'),
title: text().notNull(),
description: text(),
ownerToken: text('owner_token').notNull(),
publicToken: text('public_token').notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text(),
endDate: timestamp('end_date', { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
theme: text().default('none')
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'wishlists_user_id_user_id_fk'
}).onDelete('set null'),
unique('wishlists_owner_token_unique').on(table.ownerToken),
unique('wishlists_public_token_unique').on(table.publicToken)
]
);
export const savedWishlists = pgTable( export const items = pgTable("items", {
'saved_wishlists', id: text().primaryKey().notNull(),
{ wishlistId: text("wishlist_id").notNull(),
id: text().primaryKey().notNull(), title: text().notNull(),
userId: text('user_id').notNull(), description: text(),
wishlistId: text('wishlist_id').notNull(), link: text(),
isFavorite: boolean('is_favorite').default(false).notNull(), imageUrl: text("image_url"),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), price: numeric({ precision: 10, scale: 2 }),
ownerToken: text('owner_token') currency: text().default('DKK'),
}, color: text(),
(table) => [ order: numeric().default('0').notNull(),
foreignKey({ isReserved: boolean("is_reserved").default(false).notNull(),
columns: [table.userId], createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
foreignColumns: [user.id], updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
name: 'saved_wishlists_user_id_user_id_fk' }, (table) => [
}).onDelete('cascade'), foreignKey({
foreignKey({ columns: [table.wishlistId],
columns: [table.wishlistId], foreignColumns: [wishlists.id],
foreignColumns: [wishlists.id], name: "items_wishlist_id_wishlists_id_fk"
name: 'saved_wishlists_wishlist_id_wishlists_id_fk' }).onDelete("cascade"),
}).onDelete('cascade') ]);
]
);
export const user = pgTable( export const wishlists = pgTable("wishlists", {
'user', id: text().primaryKey().notNull(),
{ userId: text("user_id"),
id: text().primaryKey().notNull(), title: text().notNull(),
name: text(), description: text(),
email: text(), ownerToken: text("owner_token").notNull(),
emailVerified: timestamp({ mode: 'string' }), publicToken: text("public_token").notNull(),
image: text(), isFavorite: boolean("is_favorite").default(false).notNull(),
password: text(), color: text(),
username: text(), endDate: timestamp("end_date", { mode: 'string' }),
dashboardTheme: text('dashboard_theme').default('none'), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
dashboardColor: text('dashboard_color'), updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
lastLogin: timestamp('last_login', { mode: 'string' }), theme: text().default('none'),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), }, (table) => [
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull() foreignKey({
}, columns: [table.userId],
(table) => [ foreignColumns: [user.id],
unique('user_email_unique').on(table.email), name: "wishlists_user_id_user_id_fk"
unique('user_username_unique').on(table.username) }).onDelete("set null"),
] unique("wishlists_owner_token_unique").on(table.ownerToken),
); unique("wishlists_public_token_unique").on(table.publicToken),
]);
export const reservations = pgTable( export const savedWishlists = pgTable("saved_wishlists", {
'reservations', id: text().primaryKey().notNull(),
{ userId: text("user_id").notNull(),
id: text().primaryKey().notNull(), wishlistId: text("wishlist_id").notNull(),
itemId: text('item_id').notNull(), isFavorite: boolean("is_favorite").default(false).notNull(),
reserverName: text('reserver_name'), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull() ownerToken: text("owner_token"),
}, }, (table) => [
(table) => [ foreignKey({
foreignKey({ columns: [table.userId],
columns: [table.itemId], foreignColumns: [user.id],
foreignColumns: [items.id], name: "saved_wishlists_user_id_user_id_fk"
name: 'reservations_item_id_items_id_fk' }).onDelete("cascade"),
}).onDelete('cascade') foreignKey({
] columns: [table.wishlistId],
); foreignColumns: [wishlists.id],
name: "saved_wishlists_wishlist_id_wishlists_id_fk"
}).onDelete("cascade"),
]);
export const session = pgTable( export const user = pgTable("user", {
'session', id: text().primaryKey().notNull(),
{ name: text(),
sessionToken: text().primaryKey().notNull(), email: text(),
userId: text().notNull(), emailVerified: timestamp({ mode: 'string' }),
expires: timestamp({ mode: 'string' }).notNull() image: text(),
}, password: text(),
(table) => [ username: text(),
foreignKey({ dashboardTheme: text("dashboard_theme").default('none'),
columns: [table.userId], dashboardColor: text("dashboard_color"),
foreignColumns: [user.id], lastLogin: timestamp("last_login", { mode: 'string' }),
name: 'session_userId_user_id_fk' createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
}).onDelete('cascade') updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
] }, (table) => [
); unique("user_email_unique").on(table.email),
unique("user_username_unique").on(table.username),
]);
export const verificationToken = pgTable( export const reservations = pgTable("reservations", {
'verificationToken', id: text().primaryKey().notNull(),
{ itemId: text("item_id").notNull(),
identifier: text().notNull(), reserverName: text("reserver_name"),
token: text().notNull(), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
expires: timestamp({ mode: 'string' }).notNull() }, (table) => [
}, foreignKey({
(table) => [ columns: [table.itemId],
primaryKey({ foreignColumns: [items.id],
columns: [table.identifier, table.token], name: "reservations_item_id_items_id_fk"
name: 'verificationToken_identifier_token_pk' }).onDelete("cascade"),
}) ]);
]
);
export const account = pgTable( export const session = pgTable("session", {
'account', sessionToken: text().primaryKey().notNull(),
{ userId: text().notNull(),
userId: text().notNull(), expires: timestamp({ mode: 'string' }).notNull(),
type: text().notNull(), }, (table) => [
provider: text().notNull(), foreignKey({
providerAccountId: text().notNull(), columns: [table.userId],
refreshToken: text('refresh_token'), foreignColumns: [user.id],
accessToken: text('access_token'), name: "session_userId_user_id_fk"
expiresAt: numeric('expires_at'), }).onDelete("cascade"),
tokenType: text('token_type'), ]);
scope: text(),
idToken: text('id_token'), export const verificationToken = pgTable("verificationToken", {
sessionState: text('session_state') identifier: text().notNull(),
}, token: text().notNull(),
(table) => [ expires: timestamp({ mode: 'string' }).notNull(),
foreignKey({ }, (table) => [
columns: [table.userId], primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
foreignColumns: [user.id], ]);
name: 'account_userId_user_id_fk'
}).onDelete('cascade'), export const account = pgTable("account", {
primaryKey({ userId: text().notNull(),
columns: [table.provider, table.providerAccountId], type: text().notNull(),
name: 'account_provider_providerAccountId_pk' provider: text().notNull(),
}) providerAccountId: text().notNull(),
] refreshToken: text("refresh_token"),
); accessToken: text("access_token"),
expiresAt: numeric("expires_at"),
tokenType: text("token_type"),
scope: text(),
idToken: text("id_token"),
sessionState: text("session_state"),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "account_userId_user_id_fk"
}).onDelete("cascade"),
primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
]);
-47
View File
@@ -1,47 +0,0 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import svelteParser from 'svelte-eslint-parser';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
},
rules: {
// Disable overly strict Svelte navigation rules
'svelte/no-navigation-without-resolve': 'off',
'svelte/no-navigation-without-base': 'off'
}
},
// Configuration for .svelte files
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
// Configuration for .svelte.ts files (Svelte runes in TypeScript)
{
files: ['**/*.svelte.ts'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];
+51 -61
View File
@@ -1,63 +1,53 @@
{ {
"name": "wishlist-app", "name": "wishlist-app",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"postinstall": "patch-package", "postinstall": "patch-package",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio"
"lint": "prettier --check . && eslint .", },
"format": "prettier --write ." "devDependencies": {
}, "@lucide/svelte": "^0.544.0",
"devDependencies": { "@sveltejs/adapter-auto": "^7.0.0",
"@eslint/js": "^9.25.0", "@sveltejs/adapter-node": "^5.4.0",
"@lucide/svelte": "^0.544.0", "@sveltejs/kit": "^2.48.5",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/adapter-node": "^5.4.0", "@tailwindcss/vite": "^4.1.17",
"@sveltejs/kit": "^2.48.5", "@types/bcrypt": "^6.0.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "drizzle-kit": "^0.31.7",
"@tailwindcss/vite": "^4.1.17", "patch-package": "^8.0.1",
"@types/bcrypt": "^6.0.0", "postinstall-postinstall": "^2.1.0",
"drizzle-kit": "^0.31.7", "svelte": "^5.43.8",
"eslint": "^9.25.0", "svelte-check": "^4.3.4",
"eslint-plugin-svelte": "^3.5.1", "tailwindcss": "^4.1.17",
"globals": "^16.0.0", "tw-animate-css": "^1.4.0",
"patch-package": "^8.0.1", "typescript": "^5.9.3",
"postinstall-postinstall": "^2.1.0", "vite": "^7.2.2"
"prettier": "^3.5.3", },
"prettier-plugin-svelte": "^3.3.3", "dependencies": {
"svelte": "^5.43.8", "@auth/core": "^0.34.3",
"svelte-check": "^4.3.4", "@auth/drizzle-adapter": "^1.11.1",
"svelte-eslint-parser": "^1.6.0", "@auth/sveltekit": "^1.11.1",
"tailwindcss": "^4.1.17", "@internationalized/date": "^3.10.0",
"tw-animate-css": "^1.4.0", "@paralleldrive/cuid2": "^3.0.4",
"typescript": "^5.9.3", "bcrypt": "^6.0.0",
"typescript-eslint": "^8.31.0", "bits-ui": "^2.14.4",
"vite": "^7.2.2" "clsx": "^2.1.1",
}, "drizzle-orm": "^0.44.7",
"dependencies": { "lucide-svelte": "^0.554.0",
"@auth/core": "^0.34.3", "postgres": "^3.4.7",
"@auth/drizzle-adapter": "^1.11.1", "svelte-dnd-action": "^0.9.67",
"@auth/sveltekit": "^1.11.1", "tailwind-merge": "^3.4.0",
"@internationalized/date": "^3.10.0", "tailwind-variants": "^3.2.2"
"@paralleldrive/cuid2": "^3.0.4", }
"bcrypt": "^6.0.0",
"bits-ui": "^2.14.4",
"clsx": "^2.1.1",
"drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"svelte-dnd-action": "^0.9.67",
"tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2",
"zod": "^3.23.8"
}
} }
-3975
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,15 +1,15 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'tw-animate-css'; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
* { * {
transition: transition:
background-color 1s ease, background-color 1s ease,
background-image 1s ease, background-image 1s ease,
color 1s ease, color 1s ease,
border-color 1s ease; border-color 1s ease;
} }
:root { :root {
+8 -8
View File
@@ -1,14 +1,14 @@
import type { Session } from '@auth/core/types'; import type { Session } from '@auth/core/types';
declare global { declare global {
namespace App { namespace App {
interface Locals { interface Locals {
session: Session | null; session: Session | null;
} }
interface PageData { interface PageData {
session: Session | null; session: Session | null;
} }
} }
} }
export {}; export {};
+18 -19
View File
@@ -1,22 +1,21 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script> <script>
(function () { (function() {
const theme = localStorage.getItem('theme') || 'system'; const theme = localStorage.getItem('theme') || 'system';
const isDark = const isDark = theme === 'dark' ||
theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); if (isDark) {
if (isDark) { document.documentElement.classList.add('dark');
document.documentElement.classList.add('dark'); }
} })();
})(); </script>
</script> %sveltekit.head%
%sveltekit.head% </head>
</head> <body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="hover"> <div style="display: contents">%sveltekit.body%</div>
<div style="display: contents">%sveltekit.body%</div> </body>
</body>
</html> </html>
+108 -111
View File
@@ -4,131 +4,128 @@ import Credentials from '@auth/core/providers/credentials';
import Google from '@auth/core/providers/google'; import Google from '@auth/core/providers/google';
import type { OAuthConfig } from '@auth/core/providers'; import type { OAuthConfig } from '@auth/core/providers';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { users } from '$lib/db/schema'; 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 type { SvelteKitAuthConfig } from '@auth/sveltekit'; import type { SvelteKitAuthConfig } from '@auth/sveltekit';
interface AuthentikProfile {
sub: string;
email: string;
name?: string;
preferred_username?: string;
picture?: string;
}
function Authentik(config: { function Authentik(config: {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
issuer: string; issuer: string;
}): OAuthConfig<AuthentikProfile> { }): OAuthConfig<any> {
return { return {
id: 'authentik', id: 'authentik',
name: 'Authentik', name: 'Authentik',
type: 'oidc', type: 'oidc',
clientId: config.clientId, clientId: config.clientId,
clientSecret: config.clientSecret, clientSecret: config.clientSecret,
issuer: config.issuer, issuer: config.issuer,
authorization: { authorization: {
params: { params: {
scope: 'openid email profile' scope: 'openid email profile'
} }
}, },
profile(profile) { profile(profile) {
return { return {
id: profile.sub, id: profile.sub,
email: profile.email, email: profile.email,
name: profile.name || profile.preferred_username, name: profile.name || profile.preferred_username,
image: profile.picture image: profile.picture
}; };
} }
}; };
} }
const authConfig: SvelteKitAuthConfig = { const authConfig: SvelteKitAuthConfig = {
adapter: DrizzleAdapter(db), adapter: DrizzleAdapter(db),
session: { session: {
strategy: 'jwt' strategy: 'jwt'
}, },
providers: [ providers: [
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
? [ ? [
Google({ Google({
clientId: env.GOOGLE_CLIENT_ID, clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET clientSecret: env.GOOGLE_CLIENT_SECRET
}) })
] ]
: []), : []),
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER ...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
? [ ? [
Authentik({ Authentik({
clientId: env.AUTHENTIK_CLIENT_ID, clientId: env.AUTHENTIK_CLIENT_ID,
clientSecret: env.AUTHENTIK_CLIENT_SECRET, clientSecret: env.AUTHENTIK_CLIENT_SECRET,
issuer: env.AUTHENTIK_ISSUER issuer: env.AUTHENTIK_ISSUER
}) })
] ]
: []), : []),
Credentials({ Credentials({
id: 'credentials', id: 'credentials',
name: 'credentials', name: 'credentials',
credentials: { credentials: {
username: { label: 'Username', type: 'text' }, username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' } password: { label: 'Password', type: 'password' }
}, },
async authorize(credentials) { async authorize(credentials) {
if (!credentials?.username || !credentials?.password) { if (!credentials?.username || !credentials?.password) {
return null; return null;
} }
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.username, credentials.username as string) where: eq(users.username, credentials.username as string)
}); });
if (!user || !user.password) { if (!user || !user.password) {
return null; return null;
} }
const isValidPassword = await bcrypt.compare(credentials.password as string, user.password); const isValidPassword = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValidPassword) { if (!isValidPassword) {
return null; return null;
} }
return { return {
id: user.id, id: user.id,
email: user.email || undefined, email: user.email || undefined,
name: user.name, name: user.name,
image: user.image image: user.image
}; };
} }
}) })
], ],
pages: { pages: {
signIn: '/signin' signIn: '/signin'
}, },
callbacks: { callbacks: {
async signIn({ user }) { async signIn({ user }) {
if (user?.id) { if (user?.id) {
await db.update(users).set({ lastLogin: new Date() }).where(eq(users.id, user.id)); await db.update(users)
} .set({ lastLogin: new Date() })
return true; .where(eq(users.id, user.id));
}, }
async jwt({ token, user }) { return true;
if (user) { },
token.id = user.id; async jwt({ token, user }) {
} if (user) {
return token; token.id = user.id;
}, }
async session({ session, token }) { return token;
if (token && session.user) { },
session.user.id = token.id as string; async session({ session, token }) {
} if (token && session.user) {
return session; session.user.id = token.id as string;
} }
}, return session;
secret: env.AUTH_SECRET, }
trustHost: env.AUTH_TRUST_HOST === 'true' },
secret: env.AUTH_SECRET,
trustHost: env.AUTH_TRUST_HOST === 'true'
}; };
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig); export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);
@@ -1,161 +1,158 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte'; import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import { import { getLocalWishlists, forgetLocalWishlist, toggleLocalFavorite, type LocalWishlist } from '$lib/utils/localWishlists';
getLocalWishlists, import { languageStore } from '$lib/stores/language.svelte';
forgetLocalWishlist, import { Star } from 'lucide-svelte';
toggleLocalFavorite, import { onMount } from 'svelte';
type LocalWishlist
} from '$lib/utils/localWishlists';
import { languageStore } from '$lib/stores/language.svelte';
import { Star } from '@lucide/svelte';
import { onMount } from 'svelte';
let { let {
isAuthenticated = false, isAuthenticated = false,
fallbackColor = null, fallbackColor = null,
fallbackTheme = null fallbackTheme = null
}: { }: {
isAuthenticated?: boolean; isAuthenticated?: boolean;
fallbackColor?: string | null; fallbackColor?: string | null;
fallbackTheme?: string | null; fallbackTheme?: string | null;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let localWishlists = $state<LocalWishlist[]>([]); let localWishlists = $state<LocalWishlist[]>([]);
let enrichedWishlists = $state<Array<Record<string, unknown>>>([]); let enrichedWishlists = $state<any[]>([]);
onMount(async () => { onMount(async () => {
localWishlists = getLocalWishlists(); localWishlists = getLocalWishlists();
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}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
return { return {
...data, ...data,
isFavorite: local.isFavorite || false isFavorite: local.isFavorite || false
}; };
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch wishlist data:', error); console.error('Failed to fetch wishlist data:', error);
} }
return { return {
id: local.ownerToken, id: local.ownerToken,
title: local.title, title: local.title,
ownerToken: local.ownerToken, ownerToken: local.ownerToken,
publicToken: local.publicToken, publicToken: local.publicToken,
createdAt: local.createdAt, createdAt: local.createdAt,
isFavorite: local.isFavorite || false, isFavorite: local.isFavorite || false,
items: [], items: [],
theme: null, theme: null,
color: null color: null
}; };
}); });
enrichedWishlists = await Promise.all(promises); enrichedWishlists = await Promise.all(promises);
}); });
async function refreshEnrichedWishlists() { async function refreshEnrichedWishlists() {
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}`);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
return { return {
...data, ...data,
isFavorite: local.isFavorite || false isFavorite: local.isFavorite || false
}; };
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch wishlist data:', error); console.error('Failed to fetch wishlist data:', error);
} }
return { return {
id: local.ownerToken, id: local.ownerToken,
title: local.title, title: local.title,
ownerToken: local.ownerToken, ownerToken: local.ownerToken,
publicToken: local.publicToken, publicToken: local.publicToken,
createdAt: local.createdAt, createdAt: local.createdAt,
isFavorite: local.isFavorite || false, isFavorite: local.isFavorite || false,
items: [], items: [],
theme: null, theme: null,
color: null color: null
}; };
}); });
enrichedWishlists = await Promise.all(promises); enrichedWishlists = await Promise.all(promises);
} }
async function handleForget(ownerToken: string) { async function handleForget(ownerToken: string) {
forgetLocalWishlist(ownerToken); forgetLocalWishlist(ownerToken);
localWishlists = getLocalWishlists(); localWishlists = getLocalWishlists();
await refreshEnrichedWishlists(); await refreshEnrichedWishlists();
} }
async function handleToggleFavorite(ownerToken: string) { async function handleToggleFavorite(ownerToken: string) {
toggleLocalFavorite(ownerToken); toggleLocalFavorite(ownerToken);
localWishlists = getLocalWishlists(); localWishlists = getLocalWishlists();
await refreshEnrichedWishlists(); await refreshEnrichedWishlists();
} }
// Use enriched wishlists which have full data including theme and color // Use enriched wishlists which have full data including theme and color
const transformedWishlists = $derived(() => enrichedWishlists); const transformedWishlists = $derived(() => enrichedWishlists);
// Description depends on authentication status // Description depends on authentication status
const sectionDescription = $derived(() => { const sectionDescription = $derived(() => {
if (isAuthenticated) { if (isAuthenticated) {
return ( return t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet.";
t.dashboard.localWishlistsAuthDescription || }
"Wishlists stored in your browser that haven't been claimed yet." return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently.";
); });
}
return (
t.dashboard.localWishlistsDescription ||
'Wishlists stored in your browser. Sign in to save them permanently.'
);
});
</script> </script>
<WishlistSection <WishlistSection
title={t.dashboard.localWishlists || 'Local Wishlists'} title={t.dashboard.localWishlists || "Local Wishlists"}
description={sectionDescription()} description={sectionDescription()}
items={transformedWishlists()} items={transformedWishlists()}
emptyMessage={t.dashboard.emptyLocalWishlists || 'No local wishlists yet'} emptyMessage={t.dashboard.emptyLocalWishlists || "No local wishlists yet"}
emptyActionLabel={t.dashboard.createLocalWishlist || 'Create local wishlist'} emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
emptyActionHref="/" emptyActionHref="/"
showCreateButton={true} showCreateButton={true}
{fallbackColor} fallbackColor={fallbackColor}
{fallbackTheme} fallbackTheme={fallbackTheme}
> >
{#snippet actions(wishlist: Record<string, unknown>, unlocked: boolean)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onclick={() => handleToggleFavorite(wishlist.ownerToken as string)}> <Button
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> size="sm"
</Button> variant="outline"
<Button onclick={() => handleToggleFavorite(wishlist.ownerToken)}
size="sm" >
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken as string}/edit`)} <Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
> </Button>
{t.dashboard.manage} <Button
</Button> size="sm"
<Button onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
size="sm" >
variant="outline" {t.dashboard.manage}
onclick={() => { </Button>
navigator.clipboard.writeText( <Button
`${window.location.origin}/wishlist/${wishlist.publicToken as string}` size="sm"
); variant="outline"
}} onclick={() => {
> navigator.clipboard.writeText(
{t.dashboard.copyLink} `${window.location.origin}/wishlist/${wishlist.publicToken}`
</Button> );
{#if unlocked} }}
<Button size="sm" variant="destructive" onclick={() => handleForget(wishlist.ownerToken as string)}> >
{t.dashboard.forget || 'Forget'} {t.dashboard.copyLink}
</Button> </Button>
{/if} {#if unlocked}
</div> <Button
{/snippet} size="sm"
variant="destructive"
onclick={() => handleForget(wishlist.ownerToken)}
>
{t.dashboard.forget || "Forget"}
</Button>
{/if}
</div>
{/snippet}
</WishlistSection> </WishlistSection>
@@ -1,60 +1,55 @@
<script lang="ts"> <script lang="ts">
import { import { Button } from '$lib/components/ui/button';
Card, import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
CardContent, import type { Snippet } from 'svelte';
CardDescription, import { getCardStyle } from '$lib/utils/colors';
CardHeader, import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
CardTitle
} from '$lib/components/ui/card';
import type { Snippet } from 'svelte';
import { getCardStyle } from '$lib/utils/colors';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
let { let {
title, title,
description, description,
itemCount, itemCount,
color = null, color = null,
theme = null, theme = null,
fallbackColor = null, fallbackColor = null,
fallbackTheme = null, fallbackTheme = null,
children children
}: { }: {
title: string; title: string;
description?: string | null; description?: string | null;
itemCount: number; itemCount: number;
color?: string | null; color?: string | null;
theme?: string | null; theme?: string | null;
fallbackColor?: string | null; fallbackColor?: string | null;
fallbackTheme?: string | null; fallbackTheme?: string | null;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
const finalColor = $derived(color || fallbackColor); const finalColor = $derived(color || fallbackColor);
const finalTheme = $derived(theme || fallbackTheme); const finalTheme = $derived(theme || fallbackTheme);
const cardStyle = $derived(getCardStyle(color, fallbackColor)); const cardStyle = $derived(getCardStyle(color, fallbackColor));
</script> </script>
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden"> <Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
<ThemeCard themeName={finalTheme} color={finalColor} /> <ThemeCard themeName={finalTheme} color={finalColor} />
<CardHeader class="flex-shrink-0 relative z-10"> <CardHeader class="flex-shrink-0 relative z-10">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0"> <CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
<span class="truncate">{title}</span> <span class="truncate">{title}</span>
</CardTitle> </CardTitle>
<span class="text-sm text-muted-foreground flex-shrink-0"> <span class="text-sm text-muted-foreground flex-shrink-0">
{itemCount} item{itemCount === 1 ? '' : 's'} {itemCount} item{itemCount === 1 ? '' : 's'}
</span> </span>
</div> </div>
{#if description} {#if description}
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription> <CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
{/if} {/if}
</CardHeader> </CardHeader>
<CardContent class="space-y-2 flex-1 flex flex-col justify-end relative z-10"> <CardContent class="space-y-2 flex-1 flex flex-col justify-end relative z-10">
{#if children} {#if children}
<div> <div>
{@render children()} {@render children()}
</div> </div>
{/if} {/if}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,102 +1,97 @@
<script lang="ts"> <script lang="ts">
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card, import { Button } from '$lib/components/ui/button';
CardContent, import EmptyState from '$lib/components/layout/EmptyState.svelte';
CardDescription, import type { Snippet } from 'svelte';
CardHeader, import { flip } from 'svelte/animate';
CardTitle import { getCardStyle } from '$lib/utils/colors';
} from '$lib/components/ui/card'; import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import EmptyState from '$lib/components/layout/EmptyState.svelte';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { getCardStyle } from '$lib/utils/colors';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
let { let {
title, title,
description, description,
items, items,
emptyMessage, emptyMessage,
emptyDescription, emptyDescription,
emptyActionLabel, emptyActionLabel,
emptyActionHref, emptyActionHref,
fallbackColor = null, fallbackColor = null,
fallbackTheme = null, fallbackTheme = null,
headerAction, headerAction,
searchBar, searchBar,
children children
}: { }: {
title: string; title: string;
description: string; description: string;
items: Array<Record<string, unknown>>; items: any[];
emptyMessage: string; emptyMessage: string;
emptyDescription?: string; emptyDescription?: string;
emptyActionLabel?: string; emptyActionLabel?: string;
emptyActionHref?: string; emptyActionHref?: string;
fallbackColor?: string | null; fallbackColor?: string | null;
fallbackTheme?: string | null; fallbackTheme?: string | null;
headerAction?: Snippet; headerAction?: Snippet;
searchBar?: Snippet; searchBar?: Snippet;
children: Snippet<[Record<string, unknown>]>; children: Snippet<[any]>;
} = $props(); } = $props();
const cardStyle = $derived(getCardStyle(fallbackColor, null)); const cardStyle = $derived(getCardStyle(fallbackColor, null));
let scrollContainer: HTMLElement | null = null; let scrollContainer: HTMLElement | null = null;
function handleWheel(event: WheelEvent) { function handleWheel(event: WheelEvent) {
if (!scrollContainer) return; if (!scrollContainer) return;
// Check if we have horizontal overflow // Check if we have horizontal overflow
const hasHorizontalScroll = scrollContainer.scrollWidth > scrollContainer.clientWidth; const hasHorizontalScroll = scrollContainer.scrollWidth > scrollContainer.clientWidth;
if (hasHorizontalScroll && event.deltaY !== 0) { if (hasHorizontalScroll && event.deltaY !== 0) {
event.preventDefault(); event.preventDefault();
scrollContainer.scrollLeft += event.deltaY; scrollContainer.scrollLeft += event.deltaY;
} }
} }
</script> </script>
<Card style={cardStyle} class="relative overflow-hidden"> <Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={fallbackTheme} color={fallbackColor} showPattern={false} /> <ThemeCard themeName={fallbackTheme} color={fallbackColor} showPattern={false} />
<CardHeader class="relative z-10"> <CardHeader class="relative z-10">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<CardTitle>{title}</CardTitle> <CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription> <CardDescription>{description}</CardDescription>
</div> </div>
{#if headerAction} {#if headerAction}
<div class="flex-shrink-0"> <div class="flex-shrink-0">
{@render headerAction()} {@render headerAction()}
</div> </div>
{/if} {/if}
</div> </div>
{#if searchBar} {#if searchBar}
<div class="mt-4"> <div class="mt-4">
{@render searchBar()} {@render searchBar()}
</div> </div>
{/if} {/if}
</CardHeader> </CardHeader>
<CardContent class="relative z-10"> <CardContent class="relative z-10">
{#if items && items.length > 0} {#if items && items.length > 0}
<div <div
bind:this={scrollContainer} bind:this={scrollContainer}
onwheel={handleWheel} onwheel={handleWheel}
class="flex overflow-x-auto gap-4 pb-4 -mx-6 px-6" class="flex overflow-x-auto gap-4 pb-4 -mx-6 px-6"
> >
{#each items as item (item.id)} {#each items as item (item.id)}
<div class="flex-shrink-0 w-80" animate:flip={{ duration: 300 }}> <div class="flex-shrink-0 w-80" animate:flip={{ duration: 300 }}>
{@render children(item)} {@render children(item)}
</div> </div>
{/each} {/each}
</div> </div>
{:else} {:else}
<EmptyState <EmptyState
message={emptyMessage} message={emptyMessage}
description={emptyDescription} description={emptyDescription}
actionLabel={emptyActionLabel} actionLabel={emptyActionLabel}
actionHref={emptyActionHref} actionHref={emptyActionHref}
/> />
{/if} {/if}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,178 +1,166 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import WishlistGrid from '$lib/components/dashboard/WishlistGrid.svelte'; import WishlistGrid from '$lib/components/dashboard/WishlistGrid.svelte';
import WishlistCard from '$lib/components/dashboard/WishlistCard.svelte'; import WishlistCard from '$lib/components/dashboard/WishlistCard.svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { enhance } from '$app/forms';
import SearchBar from '$lib/components/ui/SearchBar.svelte'; import { Star } from 'lucide-svelte';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import type { Snippet } from 'svelte'; import SearchBar from '$lib/components/ui/SearchBar.svelte';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import type { Snippet } from 'svelte';
interface WishlistItem { type WishlistItem = any; // You can make this more specific based on your types
id?: string;
wishlist?: Record<string, unknown>;
title?: string;
description?: string;
isFavorite?: boolean;
endDate?: string | Date;
createdAt?: string | Date;
items?: Array<{ title: string }>;
user?: { name?: string; username?: string };
}
let { let {
title, title,
description, description,
items, items,
emptyMessage, emptyMessage,
emptyDescription, emptyDescription,
emptyActionLabel, emptyActionLabel,
emptyActionHref, emptyActionHref,
showCreateButton = false, showCreateButton = false,
hideIfEmpty = false, hideIfEmpty = false,
fallbackColor = null, fallbackColor = null,
fallbackTheme = null, fallbackTheme = null,
actions actions
}: { }: {
title: string; title: string;
description: string; description: string;
items: WishlistItem[]; items: WishlistItem[];
emptyMessage: string; emptyMessage: string;
emptyDescription?: string; emptyDescription?: string;
emptyActionLabel?: string; emptyActionLabel?: string;
emptyActionHref?: string; emptyActionHref?: string;
showCreateButton?: boolean; showCreateButton?: boolean;
hideIfEmpty?: boolean; hideIfEmpty?: boolean;
fallbackColor?: string | null; fallbackColor?: string | null;
fallbackTheme?: string | null; fallbackTheme?: string | null;
actions: Snippet<[WishlistItem, boolean]>; // item, unlocked actions: Snippet<[WishlistItem, boolean]>; // item, unlocked
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let unlocked = $state(false); let unlocked = $state(false);
let searchQuery = $state(''); let searchQuery = $state('');
// Filter items based on search query // Filter items based on search query
const filteredItems = $derived(() => { const filteredItems = $derived(() => {
if (!searchQuery.trim()) return items; if (!searchQuery.trim()) return items;
return items.filter((item) => { return items.filter(item => {
const title = item.title || item.wishlist?.title || ''; const title = item.title || item.wishlist?.title || '';
const description = item.description || item.wishlist?.description || ''; const description = item.description || item.wishlist?.description || '';
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
return title.toLowerCase().includes(query) || description.toLowerCase().includes(query); return title.toLowerCase().includes(query) || description.toLowerCase().includes(query);
}); });
}); });
// Sort items by favorite, end date, then created date // Sort items by favorite, end date, then created date
const sortedItems = $derived(() => { const sortedItems = $derived(() => {
return [...filteredItems()].sort((a, b) => { return [...filteredItems()].sort((a, b) => {
// Handle both direct wishlists and saved wishlists // Handle both direct wishlists and saved wishlists
const aItem = a.wishlist || a; const aItem = a.wishlist || a;
const bItem = b.wishlist || b; const bItem = b.wishlist || b;
// Sort by favorite first // Sort by favorite first
if (a.isFavorite && !b.isFavorite) return -1; if (a.isFavorite && !b.isFavorite) return -1;
if (!a.isFavorite && b.isFavorite) return 1; if (!a.isFavorite && b.isFavorite) return 1;
// Then by end date // Then by end date
const aHasEndDate = !!aItem.endDate; const aHasEndDate = !!aItem.endDate;
const bHasEndDate = !!bItem.endDate; const bHasEndDate = !!bItem.endDate;
if (aHasEndDate && !bHasEndDate) return -1; if (aHasEndDate && !bHasEndDate) return -1;
if (!aHasEndDate && bHasEndDate) return 1; if (!aHasEndDate && bHasEndDate) return 1;
if (aHasEndDate && bHasEndDate) { if (aHasEndDate && bHasEndDate) {
return new Date(aItem.endDate!).getTime() - new Date(bItem.endDate!).getTime(); return new Date(aItem.endDate!).getTime() - new Date(bItem.endDate!).getTime();
} }
// Finally by created date (most recent first) // Finally by created date (most recent first)
const aCreatedAt = a.createdAt || aItem.createdAt; const aCreatedAt = a.createdAt || aItem.createdAt;
const bCreatedAt = b.createdAt || bItem.createdAt; const bCreatedAt = b.createdAt || bItem.createdAt;
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime(); return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime();
}); });
}); });
function formatEndDate(date: Date | string | null): string | null { function formatEndDate(date: Date | string | null): string | null {
if (!date) return null; if (!date) return null;
const d = new Date(date); const d = new Date(date);
return d.toLocaleDateString(languageStore.t.date.format.short, { return d.toLocaleDateString(languageStore.t.date.format.short, { year: 'numeric', month: 'short', day: 'numeric' });
year: 'numeric', }
month: 'short',
day: 'numeric'
});
}
function getWishlistDescription(item: WishlistItem): string | null { function getWishlistDescription(item: any): string | null {
const wishlist = item.wishlist || item; const wishlist = item.wishlist || item;
if (!wishlist) return null; if (!wishlist) return null;
const lines: string[] = []; const lines: string[] = [];
const topItems = wishlist.items?.slice(0, 3).map((i: { title: string }) => i.title) || []; const topItems = wishlist.items?.slice(0, 3).map((i: any) => i.title) || [];
if (topItems.length > 0) { if (topItems.length > 0) {
lines.push(topItems.join(', ')); lines.push(topItems.join(', '));
} }
if (wishlist.user?.name || wishlist.user?.username) { if (wishlist.user?.name || wishlist.user?.username) {
const ownerName = wishlist.user.name || wishlist.user.username; const ownerName = wishlist.user.name || wishlist.user.username;
lines.push(`${t.dashboard.by} ${ownerName}`); lines.push(`${t.dashboard.by} ${ownerName}`);
} }
if (wishlist.endDate) { if (wishlist.endDate) {
lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`); lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`);
} }
return lines.length > 0 ? lines.join('\n') : null; return lines.length > 0 ? lines.join('\n') : null;
} }
// Hide entire section if hideIfEmpty is true and there are no items // Hide entire section if hideIfEmpty is true and there are no items
const shouldShow = $derived(() => { const shouldShow = $derived(() => {
return !hideIfEmpty || items.length > 0; return !hideIfEmpty || items.length > 0;
}); });
</script> </script>
{#if shouldShow()} {#if shouldShow()}
<WishlistGrid <WishlistGrid
{title} {title}
{description} {description}
items={sortedItems() || []} items={sortedItems() || []}
{emptyMessage} {emptyMessage}
{emptyDescription} {emptyDescription}
{emptyActionLabel} {emptyActionLabel}
{emptyActionHref} {emptyActionHref}
{fallbackColor} {fallbackColor}
{fallbackTheme} {fallbackTheme}
> >
{#snippet headerAction()} {#snippet headerAction()}
<div class="flex flex-col sm:flex-row gap-2"> <div class="flex flex-col sm:flex-row gap-2">
{#if showCreateButton} {#if showCreateButton}
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button> <Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
{/if} {/if}
<UnlockButton bind:unlocked /> <UnlockButton bind:unlocked />
</div> </div>
{/snippet} {/snippet}
{#snippet searchBar()} {#snippet searchBar()}
{#if items.length > 0} {#if items.length > 0}
<SearchBar bind:value={searchQuery} /> <SearchBar bind:value={searchQuery} />
{/if} {/if}
{/snippet} {/snippet}
{#snippet children(item)} {#snippet children(item)}
{@const wishlist = item.wishlist || item} {@const wishlist = item.wishlist || item}
<WishlistCard <WishlistCard
title={wishlist.title} title={wishlist.title}
description={getWishlistDescription(item)} description={getWishlistDescription(item)}
itemCount={wishlist.items?.length || 0} itemCount={wishlist.items?.length || 0}
color={wishlist.color} color={wishlist.color}
theme={wishlist.theme} theme={wishlist.theme}
{fallbackColor} fallbackColor={fallbackColor}
{fallbackTheme} fallbackTheme={fallbackTheme}
> >
{@render actions(item, unlocked)} {@render actions(item, unlocked)}
</WishlistCard> </WishlistCard>
{/snippet} {/snippet}
</WishlistGrid> </WishlistGrid>
{/if} {/if}
@@ -1,95 +1,92 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle'; import { LanguageToggle } from '$lib/components/ui/language-toggle';
import ThemePicker from '$lib/components/ui/theme-picker.svelte'; import ThemePicker from '$lib/components/ui/theme-picker.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte'; import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { signOut } from '@auth/sveltekit/client'; import { signOut } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { enhance } from '$app/forms';
let { let {
userName, userName,
userEmail, userEmail,
dashboardTheme = 'none', dashboardTheme = 'none',
dashboardColor = null, dashboardColor = null,
isAuthenticated = false, isAuthenticated = false,
onThemeUpdate, onThemeUpdate,
onColorUpdate onColorUpdate
}: { }: {
userName?: string | null; userName?: string | null;
userEmail?: string | null; userEmail?: string | null;
dashboardTheme?: string; dashboardTheme?: string;
dashboardColor?: string | null; dashboardColor?: string | null;
isAuthenticated?: boolean; isAuthenticated?: boolean;
onThemeUpdate?: (theme: string | null) => void; onThemeUpdate?: (theme: string | null) => void;
onColorUpdate?: (color: string | null) => void; onColorUpdate?: (color: string | null) => void;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
async function handleThemeChange(theme: string) { async function handleThemeChange(theme: string) {
if (onThemeUpdate) { if (onThemeUpdate) {
onThemeUpdate(theme); onThemeUpdate(theme);
} }
if (isAuthenticated) { if (isAuthenticated) {
const formData = new FormData(); const formData = new FormData();
formData.append('theme', theme); formData.append('theme', theme);
await fetch('?/updateDashboardTheme', { await fetch('?/updateDashboardTheme', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
} }
} }
let localColor = $derived(dashboardColor); let localColor = $state(dashboardColor);
async function handleColorChange() { $effect(() => {
if (onColorUpdate) { localColor = dashboardColor;
onColorUpdate(localColor); });
}
if (isAuthenticated) { async function handleColorChange() {
const formData = new FormData(); if (onColorUpdate) {
if (localColor) { onColorUpdate(localColor);
formData.append('color', localColor); }
}
await fetch('?/updateDashboardColor', { if (isAuthenticated) {
method: 'POST', const formData = new FormData();
body: formData if (localColor) {
}); formData.append('color', localColor);
} }
}
await fetch('?/updateDashboardColor', {
method: 'POST',
body: formData
});
}
}
</script> </script>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1> <h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
{#if isAuthenticated} {#if isAuthenticated}
<p class="text-muted-foreground truncate"> <p class="text-muted-foreground truncate">{t.dashboard.welcomeBack}, {userName || userEmail}</p>
{t.dashboard.welcomeBack}, {userName || userEmail} {:else}
</p> <p class="text-muted-foreground">{t.dashboard.anonymousDashboard || "Your local wishlists"}</p>
{:else} {/if}
<p class="text-muted-foreground"> </div>
{t.dashboard.anonymousDashboard || 'Your local wishlists'} <div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
</p> <ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
{/if} <ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
</div> <LanguageToggle color={localColor} />
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0"> <ThemeToggle />
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" /> {#if isAuthenticated}
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} /> <Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>
<LanguageToggle color={localColor} /> {:else}
<ThemeToggle /> <Button variant="outline" onclick={() => (window.location.href = '/signin')}>{t.auth.signIn}</Button>
{#if isAuthenticated} {/if}
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })} </div>
>{t.auth.signOut}</Button
>
{:else}
<Button variant="outline" onclick={() => (window.location.href = '/signin')}
>{t.auth.signIn}</Button
>
{/if}
</div>
</div> </div>
+39 -39
View File
@@ -1,45 +1,45 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { let {
message, message,
description, description,
actionLabel, actionLabel,
actionHref, actionHref,
onclick, onclick,
children children
}: { }: {
message: string; message: string;
description?: string; description?: string;
actionLabel?: string; actionLabel?: string;
actionHref?: string; actionHref?: string;
onclick?: () => void; onclick?: () => void;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
</script> </script>
<div class="text-center py-8 text-muted-foreground"> <div class="text-center py-8 text-muted-foreground">
<p class="text-base">{message}</p> <p class="text-base">{message}</p>
{#if description} {#if description}
<p class="text-sm mt-2">{description}</p> <p class="text-sm mt-2">{description}</p>
{/if} {/if}
{#if children} {#if children}
<div class="mt-4"> <div class="mt-4">
{@render children()} {@render children()}
</div> </div>
{:else if actionLabel} {:else if actionLabel}
<Button <Button
class="mt-4" class="mt-4"
onclick={() => { onclick={() => {
if (onclick) { if (onclick) {
onclick(); onclick();
} else if (actionHref) { } else if (actionHref) {
window.location.href = actionHref; window.location.href = actionHref;
} }
}} }}
> >
{actionLabel} {actionLabel}
</Button> </Button>
{/if} {/if}
</div> </div>
+29 -37
View File
@@ -1,44 +1,36 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle'; import { LanguageToggle } from '$lib/components/ui/language-toggle';
import { LayoutDashboard } from '@lucide/svelte'; import { LayoutDashboard } from 'lucide-svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { let {
isAuthenticated = false, isAuthenticated = false,
color = null showDashboardLink = false,
}: { color = null
isAuthenticated?: boolean; }: {
color?: string | null; isAuthenticated?: boolean;
} = $props(); showDashboardLink?: boolean;
color?: string | null;
} = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
</script> </script>
<nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full"> <nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full">
{#if isAuthenticated} {#if isAuthenticated}
<Button <Button variant="outline" size="sm" onclick={() => (window.location.href = '/dashboard')} class="px-2 sm:px-3">
variant="outline" <LayoutDashboard class="w-4 h-4" />
size="sm" <span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
onclick={() => (window.location.href = '/dashboard')} </Button>
class="px-2 sm:px-3" {:else}
> <Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')} class="px-2 sm:px-3">
<LayoutDashboard class="w-4 h-4" /> {t.auth.signIn}
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span> </Button>
</Button> {/if}
{:else} <div class="ml-auto flex items-center gap-1 sm:gap-2">
<Button <LanguageToggle {color} />
variant="outline" <ThemeToggle size="sm" {color} />
size="sm" </div>
onclick={() => (window.location.href = '/signin')}
class="px-2 sm:px-3"
>
{t.auth.signIn}
</Button>
{/if}
<div class="ml-auto flex items-center gap-1 sm:gap-2">
<LanguageToggle {color} />
<ThemeToggle size="sm" {color} />
</div>
</nav> </nav>
+27 -27
View File
@@ -1,36 +1,36 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte'; import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
import { hexToRgba } from '$lib/utils/colors'; import { hexToRgba } from '$lib/utils/colors';
import { themeStore } from '$lib/stores/theme.svelte'; import { themeStore } from '$lib/stores/theme.svelte';
let { let {
children, children,
maxWidth = '6xl', maxWidth = '6xl',
theme = null, theme = null,
themeColor = null themeColor = null
}: { }: {
children: Snippet; children: Snippet;
maxWidth?: string; maxWidth?: string;
theme?: string | null; theme?: string | null;
themeColor?: string | null; themeColor?: string | null;
} = $props(); } = $props();
const backgroundStyle = $derived.by(() => { const backgroundStyle = $derived.by(() => {
if (!themeColor) return ''; if (!themeColor) return '';
const isDark = themeStore.getResolvedTheme() === 'dark'; const isDark = themeStore.getResolvedTheme() === 'dark';
const tintedColor = hexToRgba(themeColor, 0.15); const tintedColor = hexToRgba(themeColor, 0.15);
return isDark return isDark
? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;` ? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;`
: `background-color: ${tintedColor};`; : `background-color: ${tintedColor};`;
}); });
</script> </script>
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden" style={backgroundStyle}> <div class="min-h-screen p-4 md:p-8 relative overflow-hidden" style={backgroundStyle}>
<ThemeBackground themeName={theme} color={themeColor} /> <ThemeBackground themeName={theme} color={themeColor} />
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10"> <div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
{@render children()} {@render children()}
</div> </div>
</div> </div>
@@ -1,31 +1,33 @@
<script lang="ts"> <script lang="ts">
import TopPattern from './svgs/TopPattern.svelte'; import TopPattern from './svgs/TopPattern.svelte';
import BottomPattern from './svgs/BottomPattern.svelte'; import BottomPattern from './svgs/BottomPattern.svelte';
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes'; import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
import { themeStore } from '$lib/stores/theme.svelte'; import { themeStore } from '$lib/stores/theme.svelte';
let { let {
themeName, themeName,
showTop = true, showTop = true,
showBottom = true showBottom = true,
}: { color
themeName?: string | null; }: {
showTop?: boolean; themeName?: string | null;
showBottom?: boolean; showTop?: boolean;
} = $props(); showBottom?: boolean;
color?: string;
} = $props();
const theme = $derived(getTheme(themeName)); const theme = $derived(getTheme(themeName));
const patternColor = $derived.by(() => { const patternColor = $derived.by(() => {
const isDark = themeStore.getResolvedTheme() === 'dark'; const isDark = themeStore.getResolvedTheme() === 'dark';
return isDark ? '#FFFFFF' : '#000000'; return isDark ? '#FFFFFF' : '#000000';
}); });
</script> </script>
{#if theme.pattern !== 'none'} {#if theme.pattern !== 'none'}
{#if showTop} {#if showTop}
<TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} /> <TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if} {/if}
{#if showBottom} {#if showBottom}
<BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} /> <BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if} {/if}
{/if} {/if}
+18 -16
View File
@@ -1,23 +1,25 @@
<script lang="ts"> <script lang="ts">
import CardPattern from './svgs/CardPattern.svelte'; import CardPattern from './svgs/CardPattern.svelte';
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes'; import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
import { themeStore } from '$lib/stores/theme.svelte'; import { themeStore } from '$lib/stores/theme.svelte';
let { let {
themeName, themeName,
showPattern = true color,
}: { showPattern = true
themeName?: string | null; }: {
showPattern?: boolean; themeName?: string | null;
} = $props(); color?: string | null;
showPattern?: boolean;
} = $props();
const theme = $derived(getTheme(themeName)); const theme = $derived(getTheme(themeName));
const patternColor = $derived.by(() => { const patternColor = $derived.by(() => {
const isDark = themeStore.getResolvedTheme() === 'dark'; const isDark = themeStore.getResolvedTheme() === 'dark';
return isDark ? '#FFFFFF' : '#000000'; return isDark ? '#FFFFFF' : '#000000';
}); });
</script> </script>
{#if showPattern && theme.pattern !== 'none'} {#if showPattern && theme.pattern !== 'none'}
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} /> <CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
{/if} {/if}
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import { asset } from '$app/paths'; import { asset } from '$app/paths';
let { let {
pattern = 'none', pattern = 'none',
color = '#000000', color = '#000000',
opacity = 0.1 opacity = 0.1
}: { }: {
pattern?: string; pattern?: string;
color?: string; color?: string;
opacity?: number; opacity?: number;
} = $props(); } = $props();
const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`)); const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`));
</script> </script>
{#if pattern !== 'none'} {#if pattern !== 'none'}
<div <div
class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0" class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0"
style=" style="
mask-image: url({patternPath}); mask-image: url({patternPath});
mask-size: cover; mask-size: cover;
mask-repeat: no-repeat; mask-repeat: no-repeat;
@@ -26,5 +26,5 @@
opacity: {opacity}; opacity: {opacity};
height: 100vh; height: 100vh;
" "
/> />
{/if} {/if}
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import { asset } from '$app/paths'; import { asset } from '$app/paths';
let { let {
pattern = 'none', pattern = 'none',
color = '#000000', color = '#000000',
opacity = 0.1 opacity = 0.1
}: { }: {
pattern?: string; pattern?: string;
color?: string; color?: string;
opacity?: number; opacity?: number;
} = $props(); } = $props();
const patternPath = $derived(asset(`/themes/${pattern}/item.svg`)); const patternPath = $derived(asset(`/themes/${pattern}/item.svg`));
</script> </script>
{#if pattern !== 'none'} {#if pattern !== 'none'}
<div <div
class="absolute bottom-0 top-0 right-0 pointer-events-none overflow-hidden rounded-b-lg" class="absolute bottom-0 top-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
style=" style="
mask-image: url({patternPath}); mask-image: url({patternPath});
mask-size: cover; mask-size: cover;
mask-repeat: no-repeat; mask-repeat: no-repeat;
@@ -26,5 +26,5 @@
opacity: {opacity}; opacity: {opacity};
width: 100%; width: 100%;
" "
/> />
{/if} {/if}
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import { asset } from '$app/paths'; import { asset } from '$app/paths';
let { let {
pattern = 'none', pattern = 'none',
color = '#000000', color = '#000000',
opacity = 0.1 opacity = 0.1
}: { }: {
pattern?: string; pattern?: string;
color?: string; color?: string;
opacity?: number; opacity?: number;
} = $props(); } = $props();
const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`)); const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`));
</script> </script>
{#if pattern !== 'none'} {#if pattern !== 'none'}
<div <div
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});
mask-size: cover; mask-size: cover;
mask-repeat: no-repeat; mask-repeat: no-repeat;
@@ -26,5 +26,5 @@
opacity: {opacity}; opacity: {opacity};
height: 100vh; height: 100vh;
" "
/> />
{/if} {/if}
+54 -51
View File
@@ -1,62 +1,65 @@
<script lang="ts"> <script lang="ts">
import { X, Pencil } from '@lucide/svelte'; import { X, Pencil } from 'lucide-svelte';
import IconButton from './IconButton.svelte'; import IconButton from './IconButton.svelte';
let { let {
color = $bindable(null), color = $bindable(null),
size = 'md', size = 'md',
onchange onchange
}: { }: {
color: string | null; color: string | null;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
onchange?: () => void; onchange?: () => void;
} = $props(); } = $props();
const sizeClasses = { const sizeClasses = {
sm: 'w-8 h-8', sm: 'w-8 h-8',
md: 'w-10 h-10', md: 'w-10 h-10',
lg: 'w-12 h-12' lg: 'w-12 h-12'
}; };
const iconSizeClasses = { const iconSizeClasses = {
sm: 'w-4 h-4', sm: 'w-4 h-4',
md: 'w-4 h-4', md: 'w-4 h-4',
lg: 'w-5 h-5' lg: 'w-5 h-5'
}; };
const buttonSize = sizeClasses[size]; const buttonSize = sizeClasses[size];
const iconSize = iconSizeClasses[size]; const iconSize = iconSizeClasses[size];
function handleColorChange(e: Event) { function handleColorChange(e: Event) {
color = (e.target as HTMLInputElement).value; color = (e.target as HTMLInputElement).value;
onchange?.(); onchange?.();
} }
function clearColor() { function clearColor() {
color = null; color = null;
onchange?.(); onchange?.();
} }
</script> </script>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if color} {#if color}
<IconButton onclick={clearColor} {color} {size} aria-label="Clear color" rounded="md"> <IconButton
<X class={iconSize} /> onclick={clearColor}
</IconButton> {color}
{/if} {size}
<label aria-label="Clear color"
class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden" rounded="md"
style={color ? `background-color: ${color};` : ''} >
> <X class={iconSize} />
<Pencil </IconButton>
class="{iconSize} relative z-10 pointer-events-none" {/if}
style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''} <label
/> class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
<input style={color ? `background-color: ${color};` : ''}
type="color" >
value={color || '#ffffff'} <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));' : ''} />
oninput={handleColorChange} <input
class="absolute inset-0 opacity-0 cursor-pointer" type="color"
/> value={color || '#ffffff'}
</label> oninput={handleColorChange}
class="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
</div> </div>
+113 -112
View File
@@ -1,132 +1,133 @@
<script lang="ts"> <script lang="ts">
import { scale } from 'svelte/transition'; import { Button } from '$lib/components/ui/button';
import { cubicOut } from 'svelte/easing'; import { scale } from 'svelte/transition';
import type { Snippet } from 'svelte'; import { cubicOut } from 'svelte/easing';
import IconButton from './IconButton.svelte'; import type { Snippet } from 'svelte';
import IconButton from './IconButton.svelte';
let { let {
items, items,
selectedValue, selectedValue,
onSelect, onSelect,
color, color,
showCheckmark = true, showCheckmark = true,
icon, icon,
ariaLabel ariaLabel
}: { }: {
items: Array<{ value: string; label: string }>; items: Array<{ value: string; label: string }>;
selectedValue: string; selectedValue: string;
onSelect: (value: string) => void; onSelect: (value: string) => void;
color?: string | null; color?: string | null;
showCheckmark?: boolean; showCheckmark?: boolean;
icon: Snippet; icon: Snippet;
ariaLabel: string; ariaLabel: string;
} = $props(); } = $props();
let showMenu = $state(false); let showMenu = $state(false);
const menuClasses = $derived( const menuClasses = $derived(
color 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'
: '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' : '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( const menuStyle = $derived(
color color
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);` ? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
: '' : ''
); );
function getItemStyle(itemValue: string): string { function getItemStyle(itemValue: string): string {
if (!color) return ''; if (!color) return '';
return selectedValue === itemValue ? `background-color: ${color}20;` : ''; return selectedValue === itemValue ? `background-color: ${color}20;` : '';
} }
function toggleMenu() { function toggleMenu() {
showMenu = !showMenu; showMenu = !showMenu;
} }
function handleSelect(value: string) { function handleSelect(value: string) {
onSelect(value); onSelect(value);
showMenu = false; showMenu = false;
} }
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (!target.closest('.dropdown-menu')) { if (!target.closest('.dropdown-menu')) {
showMenu = false; showMenu = false;
} }
} }
function handleMouseEnter(e: MouseEvent) { function handleMouseEnter(e: MouseEvent) {
if (color) { if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`; (e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
} }
} }
function handleMouseLeave(e: MouseEvent, itemValue: string) { function handleMouseLeave(e: MouseEvent, itemValue: string) {
if (color) { if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor = (e.currentTarget as HTMLElement).style.backgroundColor =
selectedValue === itemValue ? `${color}20` : 'transparent'; selectedValue === itemValue ? `${color}20` : 'transparent';
} }
} }
$effect(() => { $effect(() => {
if (showMenu) { if (showMenu) {
document.addEventListener('click', handleClickOutside); document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside);
} }
}); });
</script> </script>
<div class="relative dropdown-menu"> <div class="relative dropdown-menu">
<IconButton <IconButton
size="sm" size="sm"
rounded="md" rounded="md"
onclick={toggleMenu} onclick={toggleMenu}
aria-label={ariaLabel} aria-label={ariaLabel}
class={color ? 'hover-themed' : ''} class={color ? 'hover-themed' : ''}
style={color ? `--hover-bg: ${color}20;` : ''} style={color ? `--hover-bg: ${color}20;` : ''}
> >
{@render icon()} {@render icon()}
</IconButton> </IconButton>
{#if showMenu} {#if showMenu}
<div <div
class={menuClasses} class={menuClasses}
style={menuStyle} style={menuStyle}
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }} transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
> >
<div class="py-1"> <div class="py-1">
{#each items as item (item.value)} {#each items as item}
<button <button
type="button" type="button"
class="w-full text-left px-4 py-2 text-sm transition-colors" class="w-full text-left px-4 py-2 text-sm transition-colors"
class:hover:bg-slate-100={!color} class:hover:bg-slate-100={!color}
class:dark:hover:bg-slate-900={!color} class:dark:hover:bg-slate-900={!color}
class:font-bold={selectedValue === item.value} class:font-bold={selectedValue === item.value}
class:bg-slate-100={selectedValue === item.value && !color} class:bg-slate-100={selectedValue === item.value && !color}
class:dark:bg-slate-900={selectedValue === item.value && !color} class:dark:bg-slate-900={selectedValue === item.value && !color}
class:flex={showCheckmark} class:flex={showCheckmark}
class:items-center={showCheckmark} class:items-center={showCheckmark}
class:justify-between={showCheckmark} class:justify-between={showCheckmark}
style={getItemStyle(item.value)} style={getItemStyle(item.value)}
onmouseenter={handleMouseEnter} onmouseenter={handleMouseEnter}
onmouseleave={(e) => handleMouseLeave(e, item.value)} onmouseleave={(e) => handleMouseLeave(e, item.value)}
onclick={() => handleSelect(item.value)} onclick={() => handleSelect(item.value)}
> >
<span>{item.label}</span> <span>{item.label}</span>
{#if showCheckmark && selectedValue === item.value} {#if showCheckmark && selectedValue === item.value}
<span class="ml-2"></span> <span class="ml-2"></span>
{/if} {/if}
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<style> <style>
:global(.hover-themed:hover) { :global(.hover-themed:hover) {
background-color: var(--hover-bg) !important; background-color: var(--hover-bg) !important;
} }
</style> </style>
-35
View File
@@ -1,35 +0,0 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import type { Snippet } from 'svelte';
interface Props {
id: string;
label: string;
required?: boolean;
children: Snippet;
class?: string;
description?: string;
}
let {
id,
label,
required = false,
children,
class: className = '',
description
}: Props = $props();
</script>
<div class="space-y-2 {className}">
<Label for={id}>
{label}
{#if required}
<span class="text-muted-foreground">(required)</span>
{/if}
</Label>
{#if description}
<p class="text-sm text-muted-foreground">{description}</p>
{/if}
{@render children()}
</div>
+38 -39
View File
@@ -1,53 +1,52 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes { interface Props extends HTMLButtonAttributes {
color?: string | null; color?: string | null;
rounded?: 'full' | 'md' | 'lg'; rounded?: 'full' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
children: Snippet; children: Snippet;
} }
let { let {
color = null, color = null,
rounded = 'full', rounded = 'full',
size = 'md', size = 'md',
class: className = '', class: className = '',
children, children,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const sizeClasses = { const sizeClasses = {
sm: 'w-8 h-8', sm: 'w-8 h-8',
md: 'w-10 h-10', md: 'w-10 h-10',
lg: 'w-12 h-12' lg: 'w-12 h-12'
}; };
const roundedClasses = { const roundedClasses = {
full: 'rounded-full', full: 'rounded-full',
md: 'rounded-md', md: 'rounded-md',
lg: 'rounded-lg' lg: 'rounded-lg'
}; };
const baseClasses = const baseClasses = 'flex items-center justify-center border border-input transition-colors backdrop-blur';
'flex items-center justify-center border border-input transition-colors backdrop-blur'; const sizeClass = sizeClasses[size];
const sizeClass = sizeClasses[size]; const roundedClass = roundedClasses[rounded];
const roundedClass = roundedClasses[rounded];
</script> </script>
<button <button
type="button" type="button"
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm" class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
class:hover:bg-accent={!color} class:hover:bg-accent={!color}
style={color ? `--hover-bg: ${color}20;` : ''} style={color ? `--hover-bg: ${color}20;` : ''}
{...restProps} {...restProps}
> >
{@render children()} {@render children()}
</button> </button>
<style> <style>
button[style*='--hover-bg']:hover { button[style*='--hover-bg']:hover {
background-color: var(--hover-bg); background-color: var(--hover-bg);
} }
</style> </style>
+14 -10
View File
@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { let {
value = $bindable(''), value = $bindable(''),
placeholder = languageStore.t.dashboard.searchPlaceholder placeholder = languageStore.t.dashboard.searchPlaceholder
}: { }: {
value: string; value: string;
placeholder?: string; placeholder?: string;
} = $props(); } = $props();
</script> </script>
<Input type="search" {placeholder} bind:value /> <Input
type="search"
{placeholder}
bind:value
/>
-54
View File
@@ -1,54 +0,0 @@
<script lang="ts">
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { getCardStyle } from '$lib/utils/colors';
interface Props {
color?: string | null;
fallbackColor?: string | null;
theme?: string | null;
showPattern?: boolean;
title?: string;
description?: string;
children?: import('svelte').Snippet;
class?: string;
padding?: 'none' | 'normal' | 'large';
}
let {
color = null,
fallbackColor = null,
theme = null,
showPattern = false,
title,
description,
children,
class: className = '',
padding = 'normal'
}: Props = $props();
const cardStyle = $derived(getCardStyle(color, fallbackColor));
const paddingClasses = {
none: 'p-0',
normal: 'p-6',
large: 'p-12'
};
</script>
<Card style={cardStyle} class="relative overflow-hidden {className}">
<ThemeCard themeName={theme} {color} {showPattern} />
{#if title}
<CardHeader class="relative z-10">
<CardTitle>{title}</CardTitle>
{#if description}
<CardDescription>{description}</CardDescription>
{/if}
</CardHeader>
{/if}
<CardContent class="{paddingClasses[padding]} relative z-10">
{@render children?.()}
</CardContent>
</Card>
+23 -20
View File
@@ -1,27 +1,30 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Lock, LockOpen } from '@lucide/svelte'; import { Lock, LockOpen } from 'lucide-svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { let {
unlocked = $bindable(false) unlocked = $bindable(false)
}: { }: {
unlocked: boolean; unlocked: boolean;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
function handleClick() { function handleClick() {
unlocked = !unlocked; unlocked = !unlocked;
} }
</script> </script>
<Button onclick={handleClick} variant={unlocked ? 'default' : 'outline'}> <Button
{#if unlocked} onclick={handleClick}
<Lock class="mr-2 h-4 w-4" /> variant={unlocked ? "default" : "outline"}
{t.wishlist.lockDeletion} >
{:else} {#if unlocked}
<LockOpen class="mr-2 h-4 w-4" /> <Lock class="mr-2 h-4 w-4" />
{t.wishlist.unlockDeletion} {t.wishlist.lockDeletion}
{/if} {:else}
<LockOpen class="mr-2 h-4 w-4" />
{t.wishlist.unlockDeletion}
{/if}
</Button> </Button>
+72 -70
View File
@@ -1,81 +1,83 @@
<script lang="ts" module> <script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js'; import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants'; import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({ export const buttonVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0', base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', default:
destructive: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white', destructive:
outline: 'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border', outline:
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', secondary:
link: 'text-primary underline-offset-4 hover:underline' 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
}, ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
size: { link: 'text-primary underline-offset-4 hover:underline'
default: 'h-9 px-4 py-2 has-[>svg]:px-3', },
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', size: {
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', default: 'h-9 px-4 py-2 has-[>svg]:px-3',
icon: 'size-9', sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
'icon-sm': 'size-8', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
'icon-lg': 'size-10' icon: 'size-9',
} 'icon-sm': 'size-8',
}, 'icon-lg': 'size-10'
defaultVariants: { }
variant: 'default', },
size: 'default' defaultVariants: {
} variant: 'default',
}); size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant']; export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size']; export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> & export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & { WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant; variant?: ButtonVariant;
size?: ButtonSize; size?: ButtonSize;
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
let { let {
class: className, class: className,
variant = 'default', variant = 'default',
size = 'default', size = 'default',
ref = $bindable(null), ref = $bindable(null),
href = undefined, href = undefined,
type = 'button', type = 'button',
disabled, disabled,
children, children,
...restProps ...restProps
}: ButtonProps = $props(); }: ButtonProps = $props();
</script> </script>
{#if href} {#if href}
<a <a
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href} href={disabled ? undefined : href}
aria-disabled={disabled} aria-disabled={disabled}
role={disabled ? 'link' : undefined} role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined} tabindex={disabled ? -1 : undefined}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</a> </a>
{:else} {:else}
<button <button
bind:this={ref} bind:this={ref}
data-slot="button" data-slot="button"
class={cn(buttonVariants({ variant, size }), className)} class={cn(buttonVariants({ variant, size }), className)}
{type} {type}
{disabled} {disabled}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</button> </button>
{/if} {/if}
+11 -11
View File
@@ -1,16 +1,16 @@
import Root, { import Root, {
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant, type ButtonVariant,
buttonVariants buttonVariants
} from './button.svelte'; } from './button.svelte';
export { export {
Root, Root,
type ButtonProps as Props, type ButtonProps as Props,
Root as Button, Root as Button,
buttonVariants, buttonVariants,
type ButtonProps, type ButtonProps,
type ButtonSize, type ButtonSize,
type ButtonVariant type ButtonVariant
}; };
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<div class={cn('p-6 pt-0', className)} {...restProps}> <div class={cn('p-6 pt-0', className)} {...restProps}>
{@render children?.()} {@render children?.()}
</div> </div>
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLParagraphElement> & { type Props = HTMLAttributes<HTMLParagraphElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<p class={cn('text-sm text-muted-foreground', className)} {...restProps}> <p class={cn('text-sm text-muted-foreground', className)} {...restProps}>
{@render children?.()} {@render children?.()}
</p> </p>
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}> <div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}>
{@render children?.()} {@render children?.()}
</div> </div>
+7 -7
View File
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLHeadingElement> & { type Props = HTMLAttributes<HTMLHeadingElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<h3 class={cn('font-semibold leading-none tracking-tight', className)} {...restProps}> <h3 class={cn('font-semibold leading-none tracking-tight', className)} {...restProps}>
{@render children?.()} {@render children?.()}
</h3> </h3>
+11 -8
View File
@@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<div class={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...restProps}> <div
{@render children?.()} class={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...restProps}
>
{@render children?.()}
</div> </div>
+11 -11
View File
@@ -5,15 +5,15 @@ import Header from './card-header.svelte';
import Title from './card-title.svelte'; import Title from './card-title.svelte';
export { export {
Root, Root,
Content, Content,
Description, Description,
Header, Header,
Title, Title,
// //
Root as Card, Root as Card,
Content as CardContent, Content as CardContent,
Description as CardDescription, Description as CardDescription,
Header as CardHeader, Header as CardHeader,
Title as CardTitle Title as CardTitle
}; };
+13 -13
View File
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
type Props = HTMLInputAttributes & { type Props = HTMLInputAttributes & {
value?: string | number; value?: string | number;
}; };
let { class: className, type = 'text', value = $bindable(''), ...restProps }: Props = $props(); let { class: className, type = 'text', value = $bindable(''), ...restProps }: Props = $props();
</script> </script>
<input <input
{type} type={type}
class={cn( class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
bind:value bind:value
{...restProps} {...restProps}
/> />
+12 -12
View File
@@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLLabelAttributes } from 'svelte/elements'; import type { HTMLLabelAttributes } from 'svelte/elements';
type Props = HTMLLabelAttributes & { type Props = HTMLLabelAttributes & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<label <label
class={cn( class={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className className
)} )}
{...restProps} {...restProps}
> >
{@render children?.()} {@render children?.()}
</label> </label>
@@ -1,32 +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 Dropdown from '$lib/components/ui/Dropdown.svelte'; import Dropdown from '$lib/components/ui/Dropdown.svelte';
import { Languages } from '@lucide/svelte'; import { Languages } from 'lucide-svelte';
let { color }: { color?: string | null } = $props(); let { color }: { color?: string | null } = $props();
const languageItems = $derived( const languageItems = $derived(
languages.map((lang) => ({ languages.map((lang) => ({
value: lang.code, value: lang.code,
label: lang.name label: lang.name
})) }))
); );
function setLanguage(code: string) { function setLanguage(code: string) {
languageStore.setLanguage(code as 'en' | 'da'); languageStore.setLanguage(code as 'en' | 'da');
} }
</script> </script>
<Dropdown <Dropdown
items={languageItems} items={languageItems}
selectedValue={languageStore.current} selectedValue={languageStore.current}
onSelect={setLanguage} onSelect={setLanguage}
{color} {color}
showCheckmark={false} showCheckmark={false}
ariaLabel="Toggle language" ariaLabel="Toggle language"
> >
{#snippet icon()} {#snippet icon()}
<Languages class="h-[1.2rem] w-[1.2rem]" /> <Languages class="h-[1.2rem] w-[1.2rem]" />
{/snippet} {/snippet}
</Dropdown> </Dropdown>
-6
View File
@@ -1,6 +0,0 @@
import Root from './select.svelte';
export {
Root,
Root as Select
};
@@ -1,22 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils';
import type { HTMLSelectAttributes } from 'svelte/elements';
type Props = HTMLSelectAttributes & {
value?: string;
children?: import('svelte').Snippet;
};
let { class: className, value = $bindable(''), children, ...restProps }: Props = $props();
</script>
<select
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...restProps}
>
{@render children?.()}
</select>
+12 -12
View File
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import type { HTMLTextareaAttributes } from 'svelte/elements'; import type { HTMLTextareaAttributes } from 'svelte/elements';
type Props = HTMLTextareaAttributes & { type Props = HTMLTextareaAttributes & {
value?: string; value?: string;
}; };
let { class: className, value = $bindable(''), ...restProps }: Props = $props(); let { class: className, value = $bindable(''), ...restProps }: Props = $props();
</script> </script>
<textarea <textarea
class={cn( class={cn(
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className className
)} )}
bind:value bind:value
{...restProps} {...restProps}
></textarea> ></textarea>
+27 -27
View File
@@ -1,35 +1,35 @@
<script lang="ts"> <script lang="ts">
import Dropdown from '$lib/components/ui/Dropdown.svelte'; 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 color
}: { }: {
value?: string; value?: string;
onValueChange: (theme: string) => void; onValueChange: (theme: string) => void;
color?: string | null; color?: string | null;
} = $props(); } = $props();
const themeItems = $derived( const themeItems = $derived(
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({ Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
value: key, value: key,
label: theme.name label: theme.name
})) }))
); );
</script> </script>
<Dropdown <Dropdown
items={themeItems} items={themeItems}
selectedValue={value} selectedValue={value}
onSelect={onValueChange} onSelect={onValueChange}
{color} {color}
showCheckmark={true} showCheckmark={true}
ariaLabel="Select theme pattern" ariaLabel="Select theme pattern"
> >
{#snippet icon()} {#snippet icon()}
<Palette class="h-[1.2rem] w-[1.2rem]" /> <Palette class="h-[1.2rem] w-[1.2rem]" />
{/snippet} {/snippet}
</Dropdown> </Dropdown>
@@ -1,30 +1,30 @@
<script lang="ts"> <script lang="ts">
import { themeStore } from '$lib/stores/theme.svelte'; import { themeStore } from '$lib/stores/theme.svelte';
import { Sun, Moon, Monitor } from '@lucide/svelte'; import { Sun, Moon, Monitor } from 'lucide-svelte';
import IconButton from '../IconButton.svelte'; import IconButton from '../IconButton.svelte';
let { let {
color = $bindable(null), color = $bindable(null),
size = 'sm' size = 'sm',
}: { }: {
color: string | null; color: string | null;
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
} = $props(); } = $props();
function toggle() { function toggle() {
themeStore.toggle(); themeStore.toggle();
} }
</script> </script>
<IconButton onclick={toggle} {size} {color} rounded="md"> <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>
{:else if themeStore.current === 'dark'} {:else if themeStore.current === 'dark'}
<Moon size={20} /> <Moon size={20} />
<span class="sr-only">Dark mode (click for system)</span> <span class="sr-only">Dark mode (click for system)</span>
{:else} {:else}
<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}
</IconButton> </IconButton>
@@ -0,0 +1,150 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import ImageSelector from './ImageSelector.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { enhance } from '$app/forms';
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { getCardStyle } from '$lib/utils/colors';
interface Props {
onSuccess?: () => void;
wishlistColor?: string | null;
wishlistTheme?: string | null;
}
let { onSuccess, wishlistColor = null, wishlistTheme = null }: Props = $props();
const cardStyle = $derived(getCardStyle(wishlistColor, null));
const t = $derived(languageStore.t);
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
let linkUrl = $state('');
let imageUrl = $state('');
let color = $state<string | null>(null);
let scrapedImages = $state<string[]>([]);
let isLoadingImages = $state(false);
async function handleLinkChange(event: Event) {
const input = event.target as HTMLInputElement;
linkUrl = input.value;
if (linkUrl && linkUrl.startsWith('http')) {
isLoadingImages = true;
scrapedImages = [];
try {
const response = await fetch('/api/scrape-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: linkUrl })
});
if (response.ok) {
const data = await response.json();
scrapedImages = data.images || [];
}
} catch (error) {
console.error('Failed to scrape images:', error);
} finally {
isLoadingImages = false;
}
}
}
</script>
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
<CardHeader class="relative z-10">
<CardTitle>{t.form.addNewWish}</CardTitle>
</CardHeader>
<CardContent class="relative z-10">
<form
method="POST"
action="?/addItem"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
onSuccess?.();
};
}}
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 md:col-span-2">
<Label for="title">{t.form.wishName} ({t.form.required})</Label>
<Input id="title" name="title" required placeholder="e.g., Blue Headphones" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="description">{t.form.description}</Label>
<Textarea
id="description"
name="description"
placeholder="Add details about the item..."
rows={3}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="link">{t.form.link}</Label>
<Input
id="link"
name="link"
type="url"
placeholder="https://..."
bind:value={linkUrl}
oninput={handleLinkChange}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="imageUrl">{t.form.imageUrl}</Label>
<Input
id="imageUrl"
name="imageUrl"
type="url"
placeholder="https://..."
bind:value={imageUrl}
/>
<ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
</div>
<div class="space-y-2">
<Label for="price">{t.form.price}</Label>
<Input id="price" name="price" type="number" step="0.01" placeholder="0.00" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="currency">{t.form.currency}</Label>
<select
id="currency"
name="currency"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
>
{#each currencies as curr}
<option value={curr} selected={curr === 'DKK'}>{curr}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between">
<Label for="color">{t.form.cardColor}</Label>
<ColorPicker bind:color={color} />
</div>
<input type="hidden" name="color" value={color || ''} />
</div>
</div>
<Button type="submit" class="w-full md:w-auto">{t.wishlist.addWish}</Button>
</form>
</CardContent>
</Card>
@@ -1,63 +1,69 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { isLocalWishlist } from '$lib/utils/localWishlists'; import { isLocalWishlist } from '$lib/utils/localWishlists';
let { let {
isAuthenticated, isAuthenticated,
isOwner, isOwner,
hasClaimed, hasClaimed,
ownerToken ownerToken
}: { }: {
isAuthenticated: boolean; isAuthenticated: boolean;
isOwner: boolean; isOwner: boolean;
hasClaimed: boolean; hasClaimed: boolean;
ownerToken: string; ownerToken: string;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
// Check if this wishlist is in localStorage // Check if this wishlist is in localStorage
const isLocal = $derived(isLocalWishlist(ownerToken)); const isLocal = $derived(isLocalWishlist(ownerToken));
</script> </script>
{#if isAuthenticated} {#if isAuthenticated}
<div class="mb-6"> <div class="mb-6">
{#if isOwner} {#if isOwner}
<Button disabled variant="outline" class="w-full md:w-auto opacity-60 cursor-not-allowed"> <Button
{t.wishlist.youOwnThis} disabled
</Button> variant="outline"
<p class="text-sm text-muted-foreground mt-2"> class="w-full md:w-auto opacity-60 cursor-not-allowed"
{t.wishlist.alreadyInDashboard} >
</p> {t.wishlist.youOwnThis}
{:else} </Button>
<form <p class="text-sm text-muted-foreground mt-2">
method="POST" {t.wishlist.alreadyInDashboard}
action={hasClaimed ? '?/unclaimWishlist' : '?/claimWishlist'} </p>
use:enhance={() => { {:else}
return async ({ update }) => { <form
await update({ reset: false }); method="POST"
}; action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"}
}} use:enhance={() => {
> return async ({ update }) => {
<Button type="submit" variant={hasClaimed ? 'outline' : 'default'} class="w-full md:w-auto"> await update({ reset: false });
{hasClaimed ? 'Unclaim Wishlist' : 'Claim Wishlist'} };
</Button> }}
</form> >
<p class="text-sm text-muted-foreground mt-2"> <Button
{#if hasClaimed} type="submit"
You have claimed this wishlist. It will appear in your dashboard. variant={hasClaimed ? "outline" : "default"}
{:else} class="w-full md:w-auto"
Claim this wishlist to add it to your dashboard for easy access. >
{#if isLocal} {hasClaimed ? "Unclaim Wishlist" : "Claim Wishlist"}
<br /> </Button>
<span class="text-xs" </form>
>It will remain in your local wishlists and also appear in your claimed wishlists.</span <p class="text-sm text-muted-foreground mt-2">
> {#if hasClaimed}
{/if} You have claimed this wishlist. It will appear in your dashboard.
{/if} {:else}
</p> Claim this wishlist to add it to your dashboard for easy access.
{/if} {#if isLocal}
</div> <br />
<span class="text-xs">It will remain in your local wishlists and also appear in your claimed wishlists.</span>
{/if}
{/if}
</p>
{/if}
</div>
{/if} {/if}
+38 -34
View File
@@ -1,42 +1,46 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte'; import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { let {
unlocked = $bindable() unlocked = $bindable()
}: { }: {
unlocked: boolean; unlocked: boolean;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
</script> </script>
<div class="mt-12 pt-8 border-t border-border space-y-4"> <div class="mt-12 pt-8 border-t border-border space-y-4">
<div class="flex flex-col md:flex-row gap-4 justify-between items-stretch md:items-center"> <div class="flex flex-col md:flex-row gap-4 justify-between items-stretch md:items-center">
<UnlockButton bind:unlocked /> <UnlockButton bind:unlocked />
{#if unlocked} {#if unlocked}
<form <form
method="POST" method="POST"
action="?/deleteWishlist" action="?/deleteWishlist"
use:enhance={({ cancel }) => { use:enhance={({ cancel }) => {
if (!confirm(t.wishlist.deleteConfirm)) { if (!confirm(t.wishlist.deleteConfirm)) {
cancel(); cancel();
return; return;
} }
return async ({ result }) => { return async ({ result }) => {
if (result.type === 'success') { if (result.type === "success") {
window.location.href = '/dashboard'; window.location.href = "/dashboard";
} }
}; };
}} }}
> >
<Button type="submit" variant="destructive" class="w-full md:w-auto"> <Button
{t.wishlist.deleteWishlist} type="submit"
</Button> variant="destructive"
</form> class="w-full md:w-auto"
{/if} >
</div> {t.wishlist.deleteWishlist}
</Button>
</form>
{/if}
</div>
</div> </div>
@@ -0,0 +1,186 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import ImageSelector from './ImageSelector.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { enhance } from '$app/forms';
import type { Item } from '$lib/server/schema';
import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
import { getCardStyle } from '$lib/utils/colors';
interface Props {
item: Item;
onSuccess?: () => void;
onCancel?: () => void;
onColorChange?: (itemId: string, color: string) => void;
currentPosition?: number;
totalItems?: number;
onPositionChange?: (newPosition: number) => void;
wishlistColor?: string | null;
wishlistTheme?: string | null;
}
let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange, wishlistColor = null, wishlistTheme = null }: Props = $props();
const cardStyle = $derived(getCardStyle(wishlistColor, null));
const t = $derived(languageStore.t);
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
let linkUrl = $state(item.link || '');
let imageUrl = $state(item.imageUrl || '');
let color = $state<string | null>(item.color);
let scrapedImages = $state<string[]>([]);
let isLoadingImages = $state(false);
async function handleLinkChange(event: Event) {
const input = event.target as HTMLInputElement;
linkUrl = input.value;
if (linkUrl && linkUrl.startsWith('http')) {
isLoadingImages = true;
scrapedImages = [];
try {
const response = await fetch('/api/scrape-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: linkUrl })
});
if (response.ok) {
const data = await response.json();
scrapedImages = data.images || [];
}
} catch (error) {
console.error('Failed to scrape images:', error);
} finally {
isLoadingImages = false;
}
}
}
</script>
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
<CardHeader class="relative z-10">
<CardTitle>{t.wishlist.editWish}</CardTitle>
</CardHeader>
<CardContent class="relative z-10">
<form
method="POST"
action="?/updateItem"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
onSuccess?.();
};
}}
class="space-y-4"
>
<input type="hidden" name="itemId" value={item.id} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 md:col-span-2">
<Label for="title">{t.form.wishName} ({t.form.required})</Label>
<Input id="title" name="title" required value={item.title} placeholder="e.g., Blue Headphones" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="description">{t.form.description}</Label>
<Textarea
id="description"
name="description"
value={item.description || ''}
placeholder="Add details about the item..."
rows={3}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="link">{t.form.link}</Label>
<Input
id="link"
name="link"
type="url"
placeholder="https://..."
bind:value={linkUrl}
oninput={handleLinkChange}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="imageUrl">{t.form.imageUrl}</Label>
<Input
id="imageUrl"
name="imageUrl"
type="url"
placeholder="https://..."
bind:value={imageUrl}
/>
<ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
</div>
<div class="space-y-2">
<Label for="price">{t.form.price}</Label>
<Input id="price" name="price" type="number" step="0.01" value={item.price || ''} placeholder="0.00" />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="currency">{t.form.currency}</Label>
<select
id="currency"
name="currency"
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
>
{#each currencies as curr}
<option value={curr} selected={item.currency === curr}>{curr}</option>
{/each}
</select>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between">
<Label for="color">{t.form.cardColor}</Label>
<ColorPicker bind:color={color} onchange={() => onColorChange?.(item.id, color || '')} />
</div>
<input type="hidden" name="color" value={color || ''} />
</div>
<div class="space-y-2 md:col-span-2">
<Label for="position">{t.form.position}</Label>
<Input
id="position"
type="number"
min="1"
max={totalItems}
value={currentPosition}
onchange={(e) => {
const newPos = parseInt((e.target as HTMLInputElement).value);
if (newPos >= 1 && newPos <= totalItems) {
onPositionChange?.(newPos);
}
}}
placeholder="1"
/>
<p class="text-sm text-muted-foreground">
Choose where this item appears in your wishlist (1 = top, {totalItems} = bottom)
</p>
</div>
</div>
<div class="flex gap-2">
<Button type="submit" class="flex-1 md:flex-none">{t.form.saveChanges}</Button>
{#if onCancel}
<Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={onCancel}>{t.form.cancel}</Button>
{/if}
</div>
</form>
</CardContent>
</Card>
@@ -1,67 +1,83 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from "$lib/components/ui/button";
import { Card, CardContent } from '$lib/components/ui/card'; import { Card, CardContent } from "$lib/components/ui/card";
import WishlistItem from '$lib/components/wishlist/WishlistItem.svelte'; import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
import EmptyState from '$lib/components/layout/EmptyState.svelte'; import EmptyState from "$lib/components/layout/EmptyState.svelte";
import type { Item } from '$lib/server/schema'; import type { Item } from "$lib/server/schema";
import { enhance } from '$app/forms'; import { enhance } from "$app/forms";
import { flip } from 'svelte/animate'; import { flip } from "svelte/animate";
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte'; import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
import { getCardStyle } from '$lib/utils/colors'; import { getCardStyle } from "$lib/utils/colors";
let { let {
items = $bindable([]), items = $bindable([]),
rearranging, rearranging,
onStartEditing, onStartEditing,
theme = null, onReorder,
wishlistColor = null theme = null,
}: { wishlistColor = null
items: Item[]; }: {
rearranging: boolean; items: Item[];
onStartEditing: (item: Item) => void; rearranging: boolean;
theme?: string | null; onStartEditing: (item: Item) => void;
wishlistColor?: string | null; onReorder: (items: Item[]) => Promise<void>;
} = $props(); theme?: string | null;
wishlistColor?: string | null;
} = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
const cardStyle = $derived(getCardStyle(wishlistColor)); const cardStyle = $derived(getCardStyle(wishlistColor));
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
{#if items && items.length > 0} {#if items && items.length > 0}
<div class="space-y-4"> <div class="space-y-4">
{#each items as item (item.id)} {#each items as item (item.id)}
<div animate:flip={{ duration: 300 }}> <div animate:flip={{ duration: 300 }}>
<WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}> <WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onclick={() => onStartEditing(item)} onclick={() => onStartEditing(item)}
> >
{t.wishlist.edit} {t.wishlist.edit}
</Button> </Button>
{#if rearranging} {#if rearranging}
<form method="POST" action="?/deleteItem" use:enhance> <form
<input type="hidden" name="itemId" value={item.id} /> method="POST"
<Button type="submit" variant="destructive" size="sm"> action="?/deleteItem"
{t.form.delete} use:enhance
</Button> >
</form> <input
{/if} type="hidden"
</div> name="itemId"
</WishlistItem> value={item.id}
</div> />
{/each} <Button
</div> type="submit"
{:else} variant="destructive"
<Card style={cardStyle} class="relative overflow-hidden"> size="sm"
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} /> >
<CardContent class="p-12 relative z-10"> {t.form.delete}
<EmptyState message={t.wishlist.noWishes + '. ' + t.wishlist.addFirstWish + '!'} /> </Button>
</CardContent> </form>
</Card> {/if}
{/if} </div>
</WishlistItem>
</div>
{/each}
</div>
{:else}
<Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
<CardContent class="p-12 relative z-10">
<EmptyState
message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"}
/>
</CardContent>
</Card>
{/if}
</div> </div>
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
let { let {
images, images,
selectedImage = $bindable(''), selectedImage = $bindable(''),
isLoading = false isLoading = false
}: { }: {
images: string[]; images: string[];
selectedImage?: string; selectedImage?: string;
isLoading?: boolean; isLoading?: boolean;
} = $props(); } = $props();
</script> </script>
{#if isLoading} {#if isLoading}
<p class="text-sm text-muted-foreground">Loading images...</p> <p class="text-sm text-muted-foreground">Loading images...</p>
{:else if images.length > 0} {:else if images.length > 0}
<div class="mt-2"> <div class="mt-2">
<Label class="text-sm">Or select from scraped images:</Label> <Label class="text-sm">Or select from scraped images:</Label>
<div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2"> <div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2">
{#each images as imgUrl (imgUrl)} {#each images as imgUrl}
<button <button
type="button" type="button"
onclick={() => (selectedImage = imgUrl)} onclick={() => (selectedImage = imgUrl)}
class="relative aspect-square rounded-md overflow-hidden border-2 hover:border-primary transition-colors" class="relative aspect-square rounded-md overflow-hidden border-2 hover:border-primary transition-colors"
class:border-primary={selectedImage === imgUrl} class:border-primary={selectedImage === imgUrl}
> >
<img src={imgUrl} alt="" class="w-full h-full object-cover" /> <img src={imgUrl} alt="" class="w-full h-full object-cover" />
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
-241
View File
@@ -1,241 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
import { Select } from '$lib/components/ui/select';
import ThemedCard from '$lib/components/ui/ThemedCard.svelte';
import ImageSelector from './ImageSelector.svelte';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { enhance } from '$app/forms';
import type { Item } from '$lib/db/schema';
import { languageStore } from '$lib/stores/language.svelte';
import { CURRENCIES } from '$lib/utils/currency';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
success: void;
cancel: void;
colorChange: { itemId: string; color: string };
positionChange: { itemId: string; newPosition: number };
}>();
interface Props {
item?: Item | null;
mode: 'add' | 'edit';
itemCount?: number;
wishlistColor?: string | null;
wishlistTheme?: string | null;
}
let {
item = null,
mode,
itemCount = 1,
wishlistColor = null,
wishlistTheme = null
}: Props = $props();
const isEdit = mode === 'edit' && item !== null;
const t = $derived(languageStore.t);
// Form state - initialized from item if editing
let linkUrl = $state(item?.link || '');
let imageUrl = $state(item?.imageUrl || '');
let color = $state<string | null>(item?.color || null);
let position = $state(item?.order ? Number(item.order) + 1 : 1);
let scrapedImages = $state<string[]>([]);
let isLoadingImages = $state(false);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Form action based on mode
const formAction = isEdit ? '?/updateItem' : '?/addItem';
const submitLabel = isEdit ? t.form.saveChanges : t.wishlist.addWish;
const titleLabel = isEdit ? t.wishlist.editWish : t.form.addNewWish;
async function scrapeImages(url: string) {
if (!url || !url.startsWith('http')) return;
isLoadingImages = true;
scrapedImages = [];
try {
const response = await fetch('/api/scrape-images', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
if (response.ok) {
const data = await response.json();
scrapedImages = data.images || [];
}
} catch (error) {
console.error('Failed to scrape images:', error);
} finally {
isLoadingImages = false;
}
}
function handleLinkInput(event: Event) {
const input = event.target as HTMLInputElement;
linkUrl = input.value;
// Clear existing timer
if (debounceTimer) {
clearTimeout(debounceTimer);
}
// Set new timer to scrape after user stops typing for 500ms
debounceTimer = setTimeout(() => {
scrapeImages(linkUrl);
}, 500);
}
function handleColorChange() {
if (isEdit && item) {
dispatch('colorChange', { itemId: item.id, color: color || '' });
}
}
function handlePositionChange() {
if (isEdit && item) {
dispatch('positionChange', { itemId: item.id, newPosition: position });
}
}
// Auto-load images when form opens with existing URL
$effect(() => {
if (linkUrl && linkUrl.startsWith('http')) {
scrapeImages(linkUrl);
}
});
</script>
<ThemedCard theme={wishlistTheme} color={wishlistColor} title={titleLabel}>
<form
method="POST"
action={formAction}
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
dispatch('success');
};
}}
class="space-y-4"
>
{#if isEdit && item}
<input type="hidden" name="itemId" value={item.id} />
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2 md:col-span-2">
<Label for="title">{t.form.wishName} ({t.form.required})</Label>
<Input
id="title"
name="title"
required
value={item?.title || ''}
placeholder="e.g., Blue Headphones"
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="description">{t.form.description}</Label>
<Textarea
id="description"
name="description"
value={item?.description || ''}
placeholder="Add details about the item..."
rows={3}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="link">{t.form.link}</Label>
<Input
id="link"
name="link"
type="url"
placeholder="https://..."
bind:value={linkUrl}
oninput={handleLinkInput}
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="imageUrl">{t.form.imageUrl}</Label>
<Input
id="imageUrl"
name="imageUrl"
type="url"
placeholder="https://..."
bind:value={imageUrl}
/>
<ImageSelector
images={scrapedImages}
bind:selectedImage={imageUrl}
isLoading={isLoadingImages}
/>
</div>
<div class="space-y-2">
<Label for="price">{t.form.price}</Label>
<Input
id="price"
name="price"
type="number"
step="0.01"
value={item?.price || ''}
placeholder="0.00"
/>
</div>
<div class="space-y-2 md:col-span-2">
<Label for="currency">{t.form.currency}</Label>
<Select id="currency" name="currency" value={item?.currency || 'DKK'}>
{#each CURRENCIES as curr (curr)}
<option value={curr}>{curr}</option>
{/each}
</Select>
</div>
<div class="md:col-span-2">
<div class="flex items-center justify-between">
<Label for="color">{t.form.cardColor}</Label>
<ColorPicker bind:color onchange={handleColorChange} />
</div>
<input type="hidden" name="color" value={color || ''} />
</div>
{#if isEdit}
<div class="space-y-2 md:col-span-2">
<Label for="position">{t.form.position}</Label>
<Input
id="position"
name="position"
type="number"
min="1"
max={itemCount}
bind:value={position}
onchange={handlePositionChange}
placeholder="1"
/>
<p class="text-sm text-muted-foreground">
Choose where this item appears in your wishlist (1 = top, {itemCount} = bottom)
</p>
</div>
{/if}
</div>
<div class="flex gap-2">
<Button type="submit" class="flex-1 md:flex-none">{submitLabel}</Button>
{#if isEdit}
<Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={() => dispatch('cancel')}>
{t.form.cancel}
</Button>
{/if}
</div>
</form>
</ThemedCard>
@@ -1,121 +1,123 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
interface Props { interface Props {
itemId: string; itemId: string;
isReserved: boolean; isReserved: boolean;
reserverName?: string | null; reserverName?: string | null;
reservationUserId?: string | null; reservationUserId?: string | null;
currentUserId?: string | null; currentUserId?: string | null;
} }
let { itemId, isReserved, reserverName, reservationUserId, currentUserId }: Props = $props(); let { itemId, isReserved, reserverName, reservationUserId, currentUserId }: Props = $props();
let showReserveForm = $state(false); let showReserveForm = $state(false);
let name = $state(''); let name = $state('');
let showCancelConfirmation = $state(false); let showCancelConfirmation = $state(false);
const canCancel = $derived(() => { const canCancel = $derived(() => {
if (!isReserved) return false; if (!isReserved) return false;
if (reservationUserId) { if (reservationUserId) {
return currentUserId === reservationUserId; return currentUserId === reservationUserId;
} }
return true; return true;
}); });
const isAnonymousReservation = $derived(!reservationUserId); const isAnonymousReservation = $derived(!reservationUserId);
</script> </script>
{#if isReserved} {#if isReserved}
<div class="flex flex-col items-start gap-2"> <div class="flex flex-col items-start gap-2">
<div class="text-sm text-green-600 font-medium"> <div class="text-sm text-green-600 font-medium">
✓ Reserved ✓ Reserved
{#if reserverName} {#if reserverName}
by {reserverName} by {reserverName}
{/if} {/if}
</div> </div>
{#if canCancel()} {#if canCancel()}
{#if showCancelConfirmation} {#if showCancelConfirmation}
<div class="flex flex-col gap-2 items-start"> <div class="flex flex-col gap-2 items-start">
<p class="text-sm text-muted-foreground">Cancel this reservation?</p> <p class="text-sm text-muted-foreground">
<div class="flex gap-2"> Cancel this reservation?
<form </p>
method="POST" <div class="flex gap-2">
action="?/unreserve" <form method="POST" action="?/unreserve" use:enhance={() => {
use:enhance={() => { return async ({ update }) => {
return async ({ update }) => { showCancelConfirmation = false;
showCancelConfirmation = false; await update();
await update(); };
}; }}>
}} <input type="hidden" name="itemId" value={itemId} />
> <Button type="submit" variant="destructive" size="sm">
<input type="hidden" name="itemId" value={itemId} /> Yes, Cancel
<Button type="submit" variant="destructive" size="sm">Yes, Cancel</Button> </Button>
</form> </form>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onclick={() => (showCancelConfirmation = false)} onclick={() => (showCancelConfirmation = false)}
> >
No, Keep It No, Keep It
</Button> </Button>
</div> </div>
</div> </div>
{:else if isAnonymousReservation} {:else if isAnonymousReservation}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onclick={() => (showCancelConfirmation = true)} onclick={() => (showCancelConfirmation = true)}
> >
Cancel Reservation Cancel Reservation
</Button> </Button>
{:else} {:else}
<form method="POST" action="?/unreserve" use:enhance> <form method="POST" action="?/unreserve" use:enhance>
<input type="hidden" name="itemId" value={itemId} /> <input type="hidden" name="itemId" value={itemId} />
<Button type="submit" variant="outline" size="sm">Cancel Reservation</Button> <Button type="submit" variant="outline" size="sm">
</form> Cancel Reservation
{/if} </Button>
{/if} </form>
</div> {/if}
{/if}
</div>
{:else if showReserveForm} {:else if showReserveForm}
<form <form
method="POST" method="POST"
action="?/reserve" action="?/reserve"
use:enhance={() => { use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update(); await update();
showReserveForm = false; showReserveForm = false;
name = ''; name = '';
}; };
}} }}
class="flex flex-col gap-2 w-full md:w-auto" class="flex flex-col gap-2 w-full md:w-auto"
> >
<input type="hidden" name="itemId" value={itemId} /> <input type="hidden" name="itemId" value={itemId} />
<Input <Input
name="reserverName" name="reserverName"
placeholder="Your name (optional)" placeholder="Your name (optional)"
bind:value={name} bind:value={name}
class="w-full md:w-48" class="w-full md:w-48"
/> />
<div class="flex gap-2"> <div class="flex gap-2">
<Button type="submit" size="sm" class="flex-1">Confirm</Button> <Button type="submit" size="sm" class="flex-1">Confirm</Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onclick={() => (showReserveForm = false)} onclick={() => (showReserveForm = false)}
class="flex-1" class="flex-1"
> >
Cancel Cancel
</Button> </Button>
</div> </div>
</form> </form>
{:else} {:else}
<Button onclick={() => (showReserveForm = true)} size="sm" class="w-full md:w-auto"> <Button onclick={() => (showReserveForm = true)} size="sm" class="w-full md:w-auto">
Reserve This Reserve This
</Button> </Button>
{/if} {/if}
+52 -54
View File
@@ -1,66 +1,64 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Card, CardContent } from '$lib/components/ui/card'; import { Card, CardContent } from '$lib/components/ui/card';
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';
interface Props { interface Props {
publicUrl: string; publicUrl: string;
ownerUrl?: string; ownerUrl?: string;
wishlistColor?: string | null; wishlistColor?: string | null;
} }
let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props(); let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
const cardStyle = $derived(getCardStyle(null, wishlistColor)); const cardStyle = $derived(getCardStyle(null, wishlistColor));
let copiedPublic = $state(false); let copiedPublic = $state(false);
let copiedOwner = $state(false); let copiedOwner = $state(false);
const publicLink = $derived( const publicLink = $derived(
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : '' typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
); );
const ownerLink = $derived( const ownerLink = $derived(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : '');
ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : ''
);
async function copyToClipboard(text: string, type: 'public' | 'owner') { async function copyToClipboard(text: string, type: 'public' | 'owner') {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
if (type === 'public') { if (type === 'public') {
copiedPublic = true; copiedPublic = true;
setTimeout(() => (copiedPublic = false), 2000); setTimeout(() => (copiedPublic = false), 2000);
} else { } else {
copiedOwner = true; copiedOwner = true;
setTimeout(() => (copiedOwner = false), 2000); setTimeout(() => (copiedOwner = false), 2000);
} }
} }
</script> </script>
<Card style={cardStyle}> <Card style={cardStyle}>
<CardContent class="space-y-4 pt-6"> <CardContent class="space-y-4 pt-6">
<div class="space-y-2"> <div class="space-y-2">
<Label>{t.wishlist.shareViewOnly}</Label> <Label>{t.wishlist.shareViewOnly}</Label>
<div class="flex gap-2"> <div class="flex gap-2">
<Input readonly value={publicLink} class="font-mono text-sm" /> <Input readonly value={publicLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}> <Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
{copiedPublic ? t.wishlist.copied : t.wishlist.copy} {copiedPublic ? t.wishlist.copied : t.wishlist.copy}
</Button> </Button>
</div> </div>
</div> </div>
{#if ownerLink} {#if ownerLink}
<div class="space-y-2"> <div class="space-y-2">
<Label>{t.wishlist.shareEditLink}</Label> <Label>{t.wishlist.shareEditLink}</Label>
<div class="flex gap-2"> <div class="flex gap-2">
<Input readonly value={ownerLink} class="font-mono text-sm" /> <Input readonly value={ownerLink} class="font-mono text-sm" />
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}> <Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
{copiedOwner ? t.wishlist.copied : t.wishlist.copy} {copiedOwner ? t.wishlist.copied : t.wishlist.copy}
</Button> </Button>
</div> </div>
</div> </div>
{/if} {/if}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,22 +1,25 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from "$lib/components/ui/button";
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { let {
rearranging = $bindable(false), rearranging = $bindable(false),
showAddForm = false, showAddForm = false,
onToggleAddForm onToggleAddForm
}: { }: {
rearranging: boolean; rearranging: boolean;
showAddForm?: boolean; showAddForm?: boolean;
onToggleAddForm: () => void; onToggleAddForm: () => void;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
</script> </script>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<Button onclick={onToggleAddForm} class="w-full md:w-auto"> <Button
{showAddForm ? t.form.cancel : t.wishlist.addWish} onclick={onToggleAddForm}
</Button> class="w-full md:w-auto"
>
{showAddForm ? t.form.cancel : t.wishlist.addWish}
</Button>
</div> </div>
+194 -194
View File
@@ -1,212 +1,212 @@
<script lang="ts"> <script lang="ts">
import { Card, CardContent } from '$lib/components/ui/card'; import { Card, CardContent } from "$lib/components/ui/card";
import { Input } from '$lib/components/ui/input'; import { Input } from "$lib/components/ui/input";
import { Label } from '$lib/components/ui/label'; import { Label } from "$lib/components/ui/label";
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from "$lib/components/ui/textarea";
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 IconButton from "$lib/components/ui/IconButton.svelte";
import type { Wishlist } from '$lib/db/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';
let { let {
wishlist, wishlist,
onTitleUpdate, onTitleUpdate,
onDescriptionUpdate, onDescriptionUpdate,
onColorUpdate, onColorUpdate,
onEndDateUpdate, onEndDateUpdate,
onThemeUpdate onThemeUpdate
}: { }: {
wishlist: Wishlist; wishlist: Wishlist;
onTitleUpdate: (title: string) => Promise<boolean>; onTitleUpdate: (title: string) => Promise<boolean>;
onDescriptionUpdate: (description: string | null) => Promise<boolean>; onDescriptionUpdate: (description: string | null) => Promise<boolean>;
onColorUpdate: (color: string | null) => void; onColorUpdate: (color: string | null) => void;
onEndDateUpdate: (endDate: string | null) => void; onEndDateUpdate: (endDate: string | null) => void;
onThemeUpdate: (theme: string | null) => void; onThemeUpdate: (theme: string | null) => void;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let editingTitle = $state(false); let editingTitle = $state(false);
let editingDescription = $state(false); let editingDescription = $state(false);
let wishlistTitle = $state(wishlist.title); let wishlistTitle = $state(wishlist.title);
let wishlistDescription = $state(wishlist.description || ''); let wishlistDescription = $state(wishlist.description || "");
let wishlistColor = $state<string | null>(wishlist.color); let wishlistColor = $state<string | null>(wishlist.color);
let wishlistTheme = $state<string>(wishlist.theme || 'none'); let wishlistTheme = $state<string>(wishlist.theme || 'none');
let wishlistEndDate = $state<string | null>( let wishlistEndDate = $state<string | null>(
wishlist.endDate ? new Date(wishlist.endDate).toISOString().split('T')[0] : null wishlist.endDate
); ? new Date(wishlist.endDate).toISOString().split("T")[0]
: null,
);
const cardStyle = $derived(getCardStyle(null, wishlistColor)); const cardStyle = $derived(getCardStyle(null, wishlistColor));
async function saveTitle() { async function saveTitle() {
if (!wishlistTitle.trim()) { if (!wishlistTitle.trim()) {
wishlistTitle = wishlist.title; wishlistTitle = wishlist.title;
editingTitle = false; editingTitle = false;
return; return;
} }
const success = await onTitleUpdate(wishlistTitle.trim()); const success = await onTitleUpdate(wishlistTitle.trim());
if (success) { if (success) {
editingTitle = false; editingTitle = false;
} else { } else {
wishlistTitle = wishlist.title; wishlistTitle = wishlist.title;
editingTitle = false; editingTitle = false;
} }
} }
async function saveDescription() { async function saveDescription() {
const success = await onDescriptionUpdate(wishlistDescription.trim() || null); const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
if (success) { if (success) {
editingDescription = false; editingDescription = false;
} else { } else {
wishlistDescription = wishlist.description || ''; wishlistDescription = wishlist.description || "";
editingDescription = false; editingDescription = false;
} }
} }
function handleEndDateChange(e: Event) { function handleEndDateChange(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
wishlistEndDate = input.value || null; wishlistEndDate = input.value || null;
onEndDateUpdate(wishlistEndDate); onEndDateUpdate(wishlistEndDate);
} }
function clearEndDate() { function clearEndDate() {
wishlistEndDate = null; wishlistEndDate = null;
onEndDateUpdate(null); onEndDateUpdate(null);
} }
</script> </script>
<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}
<Input <Input
bind:value={wishlistTitle} bind:value={wishlistTitle}
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]" class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Enter') { if (e.key === "Enter") {
saveTitle(); saveTitle();
} else if (e.key === 'Escape') { } else if (e.key === "Escape") {
wishlistTitle = wishlist.title; wishlistTitle = wishlist.title;
editingTitle = false; editingTitle = false;
} }
}} }}
autofocus autofocus
/> />
{: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}
<IconButton <IconButton
onclick={() => { onclick={() => {
if (editingTitle) { if (editingTitle) {
saveTitle(); saveTitle();
} else { } else {
editingTitle = true; editingTitle = true;
} }
}} }}
color={wishlistColor} color={wishlistColor}
size="sm" size="sm"
class="shrink-0" class="shrink-0"
aria-label={editingTitle ? 'Save title' : 'Edit title'} aria-label={editingTitle ? "Save title" : "Edit title"}
> >
{#if editingTitle} {#if editingTitle}
<Check class="w-4 h-4" /> <Check class="w-4 h-4" />
{:else} {:else}
<Pencil class="w-4 h-4" /> <Pencil class="w-4 h-4" />
{/if} {/if}
</IconButton> </IconButton>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<ThemePicker <ThemePicker
value={wishlistTheme} value={wishlistTheme}
onValueChange={async (theme) => { onValueChange={async (theme) => {
wishlistTheme = theme; wishlistTheme = theme;
onThemeUpdate(theme); onThemeUpdate(theme);
// Force reactivity by updating the wishlist object // Force reactivity by updating the wishlist object
wishlist.theme = theme; wishlist.theme = theme;
}} }}
color={wishlistColor} color={wishlistColor}
/> />
<ColorPicker <ColorPicker
bind:color={wishlistColor} bind:color={wishlistColor}
onchange={() => onColorUpdate(wishlistColor)} onchange={() => onColorUpdate(wishlistColor)}
size="sm" size="sm"
/> />
</div> </div>
</div> </div>
<Card style={cardStyle}> <Card style={cardStyle}>
<CardContent class="pt-6 space-y-4"> <CardContent class="pt-6 space-y-4">
<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>
<IconButton <IconButton
onclick={() => { onclick={() => {
if (editingDescription) { if (editingDescription) {
saveDescription(); saveDescription();
} else { } else {
editingDescription = true; editingDescription = true;
} }
}} }}
color={wishlistColor} color={wishlistColor}
size="sm" size="sm"
class="flex-shrink-0" class="flex-shrink-0"
aria-label={editingDescription ? 'Save description' : 'Edit description'} aria-label={editingDescription ? "Save description" : "Edit description"}
> >
{#if editingDescription} {#if editingDescription}
<Check class="w-4 h-4" /> <Check class="w-4 h-4" />
{:else} {:else}
<Pencil class="w-4 h-4" /> <Pencil class="w-4 h-4" />
{/if} {/if}
</IconButton> </IconButton>
</div> </div>
{#if editingDescription} {#if editingDescription}
<Textarea <Textarea
id="wishlist-description" id="wishlist-description"
bind:value={wishlistDescription} bind:value={wishlistDescription}
class="w-full" class="w-full"
rows={3} rows={3}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
wishlistDescription = wishlist.description || ''; wishlistDescription = wishlist.description || "";
editingDescription = false; editingDescription = false;
} }
}} }}
autofocus autofocus
/> />
{:else} {:else}
<div <div class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]">
class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]" {wishlistDescription || t.form.noDescription}
> </div>
{wishlistDescription || t.form.noDescription} {/if}
</div> </div>
{/if}
</div>
<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}
<IconButton <IconButton
onclick={clearEndDate} onclick={clearEndDate}
color={wishlistColor} color={wishlistColor}
size="sm" size="sm"
class="flex-shrink-0" 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" />
</IconButton> </IconButton>
{/if} {/if}
<Input <Input
id="wishlist-end-date" id="wishlist-end-date"
type="date" type="date"
value={wishlistEndDate || ''} value={wishlistEndDate || ""}
onchange={handleEndDateChange} onchange={handleEndDateChange}
class="w-full sm:w-auto" class="w-full sm:w-auto"
/> />
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+113 -84
View File
@@ -1,96 +1,125 @@
<script lang="ts"> <script lang="ts">
import ThemedCard from '$lib/components/ui/ThemedCard.svelte'; import { Card, CardContent } from "$lib/components/ui/card";
import type { Item } from '$lib/db/schema'; import type { Item } from "$lib/server/schema";
import { GripVertical, ExternalLink } from '@lucide/svelte'; import { GripVertical, ExternalLink } from "lucide-svelte";
import { Button } from '$lib/components/ui/button'; import { getCardStyle } from '$lib/utils/colors';
import { languageStore } from '$lib/stores/language.svelte'; import { Button } from "$lib/components/ui/button";
import { formatPrice } from '$lib/utils/currency'; import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
interface Props { interface Props {
item: Item; item: Item;
showImage?: boolean; showImage?: boolean;
children?: import('svelte').Snippet; children?: any;
showDragHandle?: boolean; showDragHandle?: boolean;
theme?: string | null; theme?: string | null;
wishlistColor?: string | null; wishlistColor?: string | null;
} }
let { let {
item, item,
showImage = true, showImage = true,
children, children,
showDragHandle = false, showDragHandle = false,
theme = null, theme = null,
wishlistColor = null wishlistColor = null
}: Props = $props(); }: Props = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
const currencySymbols: Record<string, string> = {
DKK: "kr",
EUR: "€",
USD: "$",
SEK: "kr",
NOK: "kr",
GBP: "£",
};
function formatPrice(
price: string | null,
currency: string | null,
): string {
if (!price) return "";
const symbol = currency ? currencySymbols[currency] || currency : "kr";
const amount = parseFloat(price).toFixed(2);
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && ["DKK", "SEK", "NOK"].includes(currency)) {
return `${amount} ${symbol}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}
const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
</script> </script>
<ThemedCard color={item.color} fallbackColor={wishlistColor} theme={theme}> <Card style={cardStyle} class="relative overflow-hidden">
<div class="flex gap-4"> <ThemeCard themeName={theme} color={item.color} showPattern={false} />
{#if showDragHandle} <CardContent class="p-6 relative z-10">
<div <div class="flex gap-4">
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none" {#if showDragHandle}
aria-label="Drag to reorder" <div
role="button" class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
tabindex="0" aria-label="Drag to reorder"
style="touch-action: none;" role="button"
> tabindex="0"
<GripVertical class="w-6 h-6 text-muted-foreground" /> style="touch-action: none;"
</div> >
{/if} <GripVertical class="w-6 h-6 text-muted-foreground" />
</div>
{/if}
<div class="flex flex-col md:flex-row gap-4 flex-1"> <div class="flex flex-col md:flex-row gap-4 flex-1">
{#if showImage && item.imageUrl} {#if showImage && item.imageUrl}
<img <img
src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}" src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}"
alt={item.title} alt={item.title}
class="w-full md:w-32 h-32 object-cover rounded-lg" class="w-full md:w-32 h-32 object-cover rounded-lg"
onerror={(e: Event) => ((e.currentTarget as HTMLImageElement).src = item.imageUrl)} onerror={(e) => e.currentTarget.src = item.imageUrl}
/> />
{/if} {/if}
<div class="flex-1 items-center min-w-0"> <div class="flex-1 items-center min-w-0">
<div class="flex-1"> <div class="flex-1">
<h3 class="font-semibold text-lg break-words">{item.title}</h3> <h3 class="font-semibold text-lg break-words">{item.title}</h3>
</div> </div>
{#if item.description} {#if item.description}
<p <p class="text-muted-foreground break-words whitespace-pre-wrap" style="overflow-wrap: anywhere;">{item.description}</p>
class="text-muted-foreground break-words whitespace-pre-wrap" {/if}
style="overflow-wrap: anywhere;"
>
{item.description}
</p>
{/if}
<div class="flex flex-wrap gap-2 items-center text-sm mt-2"> <div class="flex flex-wrap gap-2 items-center text-sm mt-2">
{#if item.price} {#if item.price}
<span class="font-medium">{formatPrice(item.price, item.currency)}</span> <span class="font-medium"
{/if} >{formatPrice(item.price, item.currency)}</span
</div> >
{/if}
</div>
<div class="flex flex-wrap gap-2 items-center mt-3"> <div class="flex flex-wrap gap-2 items-center mt-3">
{#if item.link} {#if item.link}
<Button <Button
href={item.link} href={item.link}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
variant="outline" variant="outline"
size="sm" size="sm"
class="gap-1.5" class="gap-1.5"
> >
<ExternalLink class="w-4 h-4" /> <ExternalLink class="w-4 h-4" />
{t.wishlist.viewProduct} {t.wishlist.viewProduct}
</Button> </Button>
{/if} {/if}
{#if children} {#if children}
{@render children()} {@render children()}
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ThemedCard> </CardContent>
</Card>
-193
View File
@@ -1,193 +0,0 @@
import { pgTable, text, timestamp, numeric, boolean, primaryKey } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2';
import type { AdapterAccountType } from '@auth/core/adapters';
export const users = pgTable('user', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password').notNull(),
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'),
lastLogin: timestamp('last_login', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
);
export const wishlists = pgTable('wishlists', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
theme: text('theme').default('none'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
}));
export const items = pgTable('items', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const itemsRelations = relations(items, ({ one, many }) => ({
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
}));
export const reservations = pgTable('reservations', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const reservationsRelations = relations(reservations, ({ one }) => ({
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
}),
user: one(users, {
fields: [reservations.userId],
references: [users.id]
})
}));
export const savedWishlists = pgTable('saved_wishlists', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
ownerToken: text('owner_token'), // Stores the owner token if user has edit access (claimed via edit link)
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
}));
export const usersRelations = relations(users, ({ many }) => ({
wishlists: many(wishlists),
savedWishlists: many(savedWishlists),
reservations: many(reservations)
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Account = typeof accounts.$inferSelect;
export type NewAccount = typeof accounts.$inferInsert;
export type Session = typeof sessions.$inferSelect;
export type NewSession = typeof sessions.$inferInsert;
export type VerificationToken = typeof verificationTokens.$inferSelect;
export type NewVerificationToken = typeof verificationTokens.$inferInsert;
export type Wishlist = typeof wishlists.$inferSelect;
export type NewWishlist = typeof wishlists.$inferInsert;
export type Item = typeof items.$inferSelect;
export type NewItem = typeof items.$inferInsert;
export type Reservation = typeof reservations.$inferSelect;
export type NewReservation = typeof reservations.$inferInsert;
export type SavedWishlist = typeof savedWishlists.$inferSelect;
export type NewSavedWishlist = typeof savedWishlists.$inferInsert;
+151 -155
View File
@@ -2,166 +2,162 @@ import type { Translation } from './en';
// Danish translations // Danish translations
export const da: Translation = { export const da: Translation = {
// Navigation // Navigation
nav: { nav: {
dashboard: 'Dashboard' dashboard: 'Dashboard'
}, },
// Dashboard // Dashboard
dashboard: { dashboard: {
myWishlists: 'Mine Ønskelister', myWishlists: 'Mine Ønskelister',
myWishlistsDescription: 'Ønskelister du ejer og administrerer', myWishlistsDescription: 'Ønskelister du ejer og administrerer',
claimedWishlists: 'Ejede Ønskelister', claimedWishlists: 'Ejede Ønskelister',
claimedWishlistsDescription: 'Ønskelister du har taget ejerskab af og kan redigere', claimedWishlistsDescription: 'Ønskelister du har taget ejerskab af og kan redigere',
savedWishlists: 'Gemte Ønskelister', savedWishlists: 'Gemte Ønskelister',
savedWishlistsDescription: 'Ønskelister du følger', savedWishlistsDescription: 'Ønskelister du følger',
createNew: '+ Opret Ny', createNew: '+ Opret Ny',
manage: 'Administrer', manage: 'Administrer',
copyLink: 'Kopiér Link', copyLink: 'Kopiér Link',
viewWishlist: 'Se Ønskeliste', viewWishlist: 'Se Ønskeliste',
unsave: 'Fjern', unsave: 'Fjern',
unclaim: 'Fjern Ejerskab', unclaim: 'Fjern Ejerskab',
delete: 'Slet', delete: 'Slet',
emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.', emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.',
emptyWishlistsAction: 'Opret Din Første Ønskeliste', emptyWishlistsAction: 'Opret Din Første Ønskeliste',
emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.', emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.',
emptyClaimedWishlistsDescription: emptyClaimedWishlistsDescription: 'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.', emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.', emptySavedWishlistsDescription: 'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
emptySavedWishlistsDescription: by: 'af',
'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.', ends: 'Slutter',
by: 'af', welcomeBack: 'Velkommen tilbage',
ends: 'Slutter', searchPlaceholder: 'Søg ønsker...'
welcomeBack: 'Velkommen tilbage', },
searchPlaceholder: 'Søg ønsker...'
},
// Wishlist // Wishlist
wishlist: { wishlist: {
title: 'Ønskeliste', title: 'Ønskeliste',
createTitle: 'Opret Din Ønskeliste', createTitle: 'Opret Din Ønskeliste',
createDescription: 'Opret en ønskeliste og del den med venner og familie', createDescription: 'Opret en ønskeliste og del den med venner og familie',
addWish: '+ Tilføj Ønske', addWish: '+ Tilføj Ønske',
editWish: 'Rediger Ønske', editWish: 'Rediger Ønske',
deleteWish: 'Slet Ønske', deleteWish: 'Slet Ønske',
reserve: 'Reservér', reserve: 'Reservér',
unreserve: 'Fjern Reservation', unreserve: 'Fjern Reservation',
reserved: 'Reserveret', reserved: 'Reserveret',
reservedBy: 'af', reservedBy: 'af',
save: 'Gem', save: 'Gem',
saveWishlist: 'Gem Ønskeliste', saveWishlist: 'Gem Ønskeliste',
unsaveWishlist: 'Fjern', unsaveWishlist: 'Fjern',
share: 'Del', share: 'Del',
edit: 'Rediger', edit: 'Rediger',
back: 'Tilbage', back: 'Tilbage',
noWishes: 'Ingen ønsker endnu', noWishes: 'Ingen ønsker endnu',
addFirstWish: 'Tilføj dit første ønske', addFirstWish: 'Tilføj dit første ønske',
emptyWishes: 'Denne ønskeliste har ingen ønsker endnu.', emptyWishes: 'Denne ønskeliste har ingen ønsker endnu.',
viewProduct: 'Se Produkt', viewProduct: 'Se Produkt',
claimWishlist: 'Tag Ejerskab Af Ønskeliste', claimWishlist: 'Tag Ejerskab Af Ønskeliste',
unclaimWishlist: 'Fjern Ejerskab Af Ønskeliste', unclaimWishlist: 'Fjern Ejerskab Af Ønskeliste',
youOwnThis: 'Du Ejer Denne Ønskeliste', youOwnThis: 'Du Ejer Denne Ønskeliste',
youClaimedThis: 'Du Har Taget Ejerskab Af Denne Ønskeliste', youClaimedThis: 'Du Har Taget Ejerskab Af Denne Ønskeliste',
alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.', alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.',
alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.', alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.',
claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard', claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard',
claimedDescription: claimedDescription: 'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard', deleteWishlist: 'Slet Ønskeliste',
deleteWishlist: 'Slet Ønskeliste', deleteConfirm: 'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
deleteConfirm: lockDeletion: 'Lås Sletning',
'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.', unlockDeletion: 'Lås Op for Sletning',
lockDeletion: 'Lås Sletning', shareViewOnly: 'Del med venner (afslører reservationer)',
unlockDeletion: 'Lås Op for Sletning', shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)',
shareViewOnly: 'Del med venner (afslører reservationer)', copy: 'Kopiér',
shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)', copied: 'Kopieret!',
copy: 'Kopiér', signInToSave: 'Log ind for at gemme',
copied: 'Kopieret!', saveThisWishlist: 'Gem Denne Ønskeliste',
signInToSave: 'Log ind for at gemme', saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard',
saveThisWishlist: 'Gem Denne Ønskeliste', creating: 'Opretter...',
saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard', createWishlist: 'Opret Ønskeliste'
creating: 'Opretter...', },
createWishlist: 'Opret Ønskeliste'
},
// Forms // Forms
form: { form: {
title: 'Titel', title: 'Titel',
wishlistTitle: 'Ønskeliste Titel', wishlistTitle: 'Ønskeliste Titel',
wishlistTitlePlaceholder: 'Min Fødselsdagsønskeliste', wishlistTitlePlaceholder: 'Min Fødselsdagsønskeliste',
description: 'Beskrivelse', description: 'Beskrivelse',
descriptionPlaceholder: 'Tilføj kontekst til din ønskeliset', descriptionPlaceholder: 'Tilføj kontekst til din ønskeliset',
descriptionOptional: 'Beskrivelse (valgfri)', descriptionOptional: 'Beskrivelse (valgfri)',
noDescription: 'Ingen beskrivelse', noDescription: 'Ingen beskrivelse',
price: 'Pris', price: 'Pris',
currency: 'Valuta', currency: 'Valuta',
url: 'URL', url: 'URL',
link: 'Link (URL)', link: 'Link (URL)',
image: 'Billede', image: 'Billede',
imageUrl: 'Billede URL', imageUrl: 'Billede URL',
submit: 'Indsend', submit: 'Indsend',
cancel: 'Annuller', cancel: 'Annuller',
save: 'Gem', save: 'Gem',
saveChanges: 'Gem Ændringer', saveChanges: 'Gem Ændringer',
delete: 'Slet', delete: 'Slet',
email: 'E-mail', email: 'E-mail',
password: 'Adgangskode', password: 'Adgangskode',
confirmPassword: 'Bekræft Adgangskode', confirmPassword: 'Bekræft Adgangskode',
name: 'Navn', name: 'Navn',
username: 'Brugernavn', username: 'Brugernavn',
wishName: 'Ønskenavn', wishName: 'Ønskenavn',
yourName: 'Dit navn', yourName: 'Dit navn',
optional: 'valgfri', optional: 'valgfri',
required: 'påkrævet', required: 'påkrævet',
color: 'Farve', color: 'Farve',
wishlistColor: 'Ønskeliste Farve (valgfri)', wishlistColor: 'Ønskeliste Farve (valgfri)',
cardColor: 'Kortfarve (valgfri)', cardColor: 'Kortfarve (valgfri)',
endDate: 'Slutdato', endDate: 'Slutdato',
endDateOptional: 'Slutdato (valgfri)', endDateOptional: 'Slutdato (valgfri)',
position: 'Position i Listen', position: 'Position i Listen',
addNewWish: 'Tilføj Nyt Ønske' addNewWish: 'Tilføj Nyt Ønske'
}, },
// Auth // Auth
auth: { auth: {
signIn: 'Log Ind', signIn: 'Log Ind',
signUp: 'Tilmeld', signUp: 'Tilmeld',
signOut: 'Log Ud', signOut: 'Log Ud',
signingIn: 'Logger ind...', signingIn: 'Logger ind...',
welcome: 'Velkommen', welcome: 'Velkommen',
welcomeBack: 'Velkommen Tilbage', welcomeBack: 'Velkommen Tilbage',
signInPrompt: 'Log ind på din konto', signInPrompt: 'Log ind på din konto',
signUpPrompt: 'Tilmeld dig for at administrere dine ønskelister', signUpPrompt: 'Tilmeld dig for at administrere dine ønskelister',
createAccount: 'Opret en Konto', createAccount: 'Opret en Konto',
alreadyHaveAccount: 'Har du allerede en konto?', alreadyHaveAccount: 'Har du allerede en konto?',
dontHaveAccount: 'Har du ikke en konto?', dontHaveAccount: 'Har du ikke en konto?',
continueWith: 'Eller fortsæt med' continueWith: 'Eller fortsæt med'
}, },
// Common // Common
common: { common: {
loading: 'Indlæser...', loading: 'Indlæser...',
error: 'Fejl', error: 'Fejl',
success: 'Succes', success: 'Succes',
confirm: 'Bekræft', confirm: 'Bekræft',
close: 'Luk', close: 'Luk',
or: 'eller', or: 'eller',
and: 'og' and: 'og'
}, },
// Reservation // Reservation
reservation: { reservation: {
reserveThis: 'Reservér Denne', reserveThis: 'Reservér Denne',
cancelReservation: 'Annuller Reservation', cancelReservation: 'Annuller Reservation',
yourNameOptional: 'Dit navn (valgfri)', yourNameOptional: 'Dit navn (valgfri)',
confirm: 'Bekræft', confirm: 'Bekræft',
cancel: 'Annuller' cancel: 'Annuller'
}, },
// Date formatting // Date formatting
date: { date: {
format: { format: {
short: 'da-DK', short: 'da-DK',
long: 'da-DK' long: 'da-DK'
} }
} }
}; };
+151 -153
View File
@@ -1,164 +1,162 @@
export const en = { export const en = {
// Navigation // Navigation
nav: { nav: {
dashboard: 'Dashboard' dashboard: 'Dashboard'
}, },
// Dashboard // Dashboard
dashboard: { dashboard: {
myWishlists: 'My Wishlists', myWishlists: 'My Wishlists',
myWishlistsDescription: 'Wishlists you own and manage', myWishlistsDescription: 'Wishlists you own and manage',
claimedWishlists: 'Claimed Wishlists', claimedWishlists: 'Claimed Wishlists',
claimedWishlistsDescription: 'Wishlists you have claimed and can edit', claimedWishlistsDescription: 'Wishlists you have claimed and can edit',
savedWishlists: 'Saved Wishlists', savedWishlists: 'Saved Wishlists',
savedWishlistsDescription: "Wishlists you're following", savedWishlistsDescription: "Wishlists you're following",
createNew: '+ Create New', createNew: '+ Create New',
manage: 'Manage', manage: 'Manage',
copyLink: 'Copy Link', copyLink: 'Copy Link',
viewWishlist: 'View Wishlist', viewWishlist: 'View Wishlist',
unsave: 'Unsave', unsave: 'Unsave',
unclaim: 'Unclaim', unclaim: 'Unclaim',
delete: 'Delete', delete: 'Delete',
emptyWishlists: "You haven't created any wishlists yet.", emptyWishlists: "You haven't created any wishlists yet.",
emptyWishlistsAction: 'Create Your First Wishlist', emptyWishlistsAction: 'Create Your First Wishlist',
emptyClaimedWishlists: "You haven't claimed any wishlists yet.", emptyClaimedWishlists: "You haven't claimed any wishlists yet.",
emptyClaimedWishlistsDescription: emptyClaimedWishlistsDescription: "When someone shares an edit link with you, you can claim it to manage it from your dashboard.",
'When someone shares an edit link with you, you can claim it to manage it from your dashboard.', emptySavedWishlists: "You haven't saved any wishlists yet.",
emptySavedWishlists: "You haven't saved any wishlists yet.", emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.",
emptySavedWishlistsDescription: by: 'by',
"When viewing someone's wishlist, you can save it to easily find it later.", ends: 'Ends',
by: 'by', welcomeBack: 'Welcome back',
ends: 'Ends', searchPlaceholder: 'Search wishes...'
welcomeBack: 'Welcome back', },
searchPlaceholder: 'Search wishes...'
},
// Wishlist // Wishlist
wishlist: { wishlist: {
title: 'Wishlist', title: 'Wishlist',
createTitle: 'Create Your Wishlist', createTitle: 'Create Your Wishlist',
createDescription: 'Create a wishlist and share it with friends and family', createDescription: 'Create a wishlist and share it with friends and family',
addWish: '+ Add Wish', addWish: '+ Add Wish',
editWish: 'Edit Wish', editWish: 'Edit Wish',
deleteWish: 'Delete Wish', deleteWish: 'Delete Wish',
reserve: 'Reserve', reserve: 'Reserve',
unreserve: 'Unreserve', unreserve: 'Unreserve',
reserved: 'Reserved', reserved: 'Reserved',
reservedBy: 'by', reservedBy: 'by',
save: 'Save', save: 'Save',
saveWishlist: 'Save Wishlist', saveWishlist: 'Save Wishlist',
unsaveWishlist: 'Unsave', unsaveWishlist: 'Unsave',
share: 'Share', share: 'Share',
edit: 'Edit', edit: 'Edit',
back: 'Back', back: 'Back',
noWishes: 'No wishes yet', noWishes: 'No wishes yet',
addFirstWish: 'Add your first wish', addFirstWish: 'Add your first wish',
emptyWishes: "This wishlist doesn't have any wishes yet.", emptyWishes: "This wishlist doesn't have any wishes yet.",
viewProduct: 'View Product', viewProduct: 'View Product',
claimWishlist: 'Claim Wishlist', claimWishlist: 'Claim Wishlist',
unclaimWishlist: 'Unclaim Wishlist', unclaimWishlist: 'Unclaim Wishlist',
youOwnThis: 'You Own This Wishlist', youOwnThis: 'You Own This Wishlist',
youClaimedThis: 'You Have Claimed This Wishlist', youClaimedThis: 'You Have Claimed This Wishlist',
alreadyInDashboard: 'This wishlist is already in your dashboard as the owner.', alreadyInDashboard: 'This wishlist is already in your dashboard as the owner.',
alreadyClaimed: 'This wishlist is already in your dashboard as a claimed wishlist.', alreadyClaimed: 'This wishlist is already in your dashboard as a claimed wishlist.',
claimDescription: 'Claim this wishlist to add it to your dashboard', claimDescription: 'Claim this wishlist to add it to your dashboard',
claimedDescription: 'You have claimed this wishlist and can access it from your dashboard', claimedDescription: 'You have claimed this wishlist and can access it from your dashboard',
deleteWishlist: 'Delete Wishlist', deleteWishlist: 'Delete Wishlist',
deleteConfirm: 'Are you sure you want to delete this wishlist? This action cannot be undone.', deleteConfirm: 'Are you sure you want to delete this wishlist? This action cannot be undone.',
lockDeletion: 'Lock Deletion', lockDeletion: 'Lock Deletion',
unlockDeletion: 'Unlock for Deletion', unlockDeletion: 'Unlock for Deletion',
shareViewOnly: 'Share with friends (view only)', shareViewOnly: 'Share with friends (view only)',
shareEditLink: 'Your edit link (keep this private!)', shareEditLink: 'Your edit link (keep this private!)',
copy: 'Copy', copy: 'Copy',
copied: 'Copied!', copied: 'Copied!',
signInToSave: 'Sign in to Save', signInToSave: 'Sign in to Save',
saveThisWishlist: 'Save This Wishlist', saveThisWishlist: 'Save This Wishlist',
saveDescription: 'Save this wishlist to easily find it later in your dashboard', saveDescription: 'Save this wishlist to easily find it later in your dashboard',
creating: 'Creating...', creating: 'Creating...',
createWishlist: 'Create Wishlist' createWishlist: 'Create Wishlist'
}, },
// Forms // Forms
form: { form: {
title: 'Title', title: 'Title',
wishlistTitle: 'Wishlist Title', wishlistTitle: 'Wishlist Title',
wishlistTitlePlaceholder: 'My Birthday Wishlist', wishlistTitlePlaceholder: 'My Birthday Wishlist',
description: 'Description', description: 'Description',
descriptionPlaceholder: 'Add some context for your wishlist...', descriptionPlaceholder: 'Add some context for your wishlist...',
descriptionOptional: 'Description (optional)', descriptionOptional: 'Description (optional)',
noDescription: 'No description', noDescription: 'No description',
price: 'Price', price: 'Price',
currency: 'Currency', currency: 'Currency',
url: 'URL', url: 'URL',
link: 'Link (URL)', link: 'Link (URL)',
image: 'Image', image: 'Image',
imageUrl: 'Image URL', imageUrl: 'Image URL',
submit: 'Submit', submit: 'Submit',
cancel: 'Cancel', cancel: 'Cancel',
save: 'Save', save: 'Save',
saveChanges: 'Save Changes', saveChanges: 'Save Changes',
delete: 'Delete', delete: 'Delete',
email: 'Email', email: 'Email',
password: 'Password', password: 'Password',
confirmPassword: 'Confirm Password', confirmPassword: 'Confirm Password',
name: 'Name', name: 'Name',
username: 'Username', username: 'Username',
wishName: 'Wish Name', wishName: 'Wish Name',
yourName: 'Your name', yourName: 'Your name',
optional: 'optional', optional: 'optional',
required: 'required', required: 'required',
color: 'Color', color: 'Color',
wishlistColor: 'Wishlist Color (optional)', wishlistColor: 'Wishlist Color (optional)',
cardColor: 'Card Color (optional)', cardColor: 'Card Color (optional)',
endDate: 'End Date', endDate: 'End Date',
endDateOptional: 'End Date (optional)', endDateOptional: 'End Date (optional)',
position: 'Position in List', position: 'Position in List',
addNewWish: 'Add New Wish' addNewWish: 'Add New Wish'
}, },
// Auth // Auth
auth: { auth: {
signIn: 'Sign In', signIn: 'Sign In',
signUp: 'Sign Up', signUp: 'Sign Up',
signOut: 'Sign Out', signOut: 'Sign Out',
signingIn: 'Signing in...', signingIn: 'Signing in...',
welcome: 'Welcome', welcome: 'Welcome',
welcomeBack: 'Welcome Back', welcomeBack: 'Welcome Back',
signInPrompt: 'Sign in to your account', signInPrompt: 'Sign in to your account',
signUpPrompt: 'Sign up to manage your wishlists', signUpPrompt: 'Sign up to manage your wishlists',
createAccount: 'Create an Account', createAccount: 'Create an Account',
alreadyHaveAccount: 'Already have an account?', alreadyHaveAccount: 'Already have an account?',
dontHaveAccount: "Don't have an account?", dontHaveAccount: "Don't have an account?",
continueWith: 'Or continue with' continueWith: 'Or continue with'
}, },
// Common // Common
common: { common: {
loading: 'Loading...', loading: 'Loading...',
error: 'Error', error: 'Error',
success: 'Success', success: 'Success',
confirm: 'Confirm', confirm: 'Confirm',
close: 'Close', close: 'Close',
or: 'or', or: 'or',
and: 'and' and: 'and'
}, },
// Reservation // Reservation
reservation: { reservation: {
reserveThis: 'Reserve This', reserveThis: 'Reserve This',
cancelReservation: 'Cancel Reservation', cancelReservation: 'Cancel Reservation',
yourNameOptional: 'Your name (optional)', yourNameOptional: 'Your name (optional)',
confirm: 'Confirm', confirm: 'Confirm',
cancel: 'Cancel' cancel: 'Cancel'
}, },
// Date formatting // Date formatting
date: { date: {
format: { format: {
short: 'en-US', short: 'en-US',
long: 'en-US' long: 'en-US'
} }
} }
}; };
export type Translation = typeof en; export type Translation = typeof en;
+4 -4
View File
@@ -3,13 +3,13 @@ import { da } from './da';
import type { Translation } from './en'; import type { Translation } from './en';
export const translations: Record<string, Translation> = { export const translations: Record<string, Translation> = {
en, en,
da da
}; };
export const languages = [ export const languages = [
{ code: 'en', name: 'English' }, { code: 'en', name: 'English' },
{ code: 'da', name: 'Dansk' } { code: 'da', name: 'Dansk' }
] as const; ] as const;
export type LanguageCode = 'en' | 'da'; export type LanguageCode = 'en' | 'da';
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -1,7 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import * as schema from '$lib/db/schema'; import * as schema from './schema';
const client = postgres(env.DATABASE_URL!); const client = postgres(env.DATABASE_URL!);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });
+179
View File
@@ -0,0 +1,179 @@
import { pgTable, text, timestamp, numeric, boolean, primaryKey } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2';
import type { AdapterAccountType } from '@auth/core/adapters';
export const users = pgTable('user', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password').notNull(),
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'),
lastLogin: timestamp('last_login', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
);
export const wishlists = pgTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
theme: text('theme').default('none'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
}));
export const items = pgTable('items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const itemsRelations = relations(items, ({ one, many }) => ({
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
}));
export const reservations = pgTable('reservations', {
id: text('id').primaryKey().$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const reservationsRelations = relations(reservations, ({ one }) => ({
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
}),
user: one(users, {
fields: [reservations.userId],
references: [users.id]
})
}));
export const savedWishlists = pgTable('saved_wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
ownerToken: text('owner_token'), // Stores the owner token if user has edit access (claimed via edit link)
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
}));
export const usersRelations = relations(users, ({ many }) => ({
wishlists: many(wishlists),
savedWishlists: many(savedWishlists),
reservations: many(reservations)
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Wishlist = typeof wishlists.$inferSelect;
export type NewWishlist = typeof wishlists.$inferInsert;
export type Item = typeof items.$inferSelect;
export type NewItem = typeof items.$inferInsert;
export type Reservation = typeof reservations.$inferSelect;
export type NewReservation = typeof reservations.$inferInsert;
export type SavedWishlist = typeof savedWishlists.$inferSelect;
export type NewSavedWishlist = typeof savedWishlists.$inferInsert;
+63 -109
View File
@@ -1,132 +1,86 @@
export function sanitizeString( export function sanitizeString(input: string | null | undefined, maxLength: number = 1000): string | null {
input: string | null | undefined, if (input === null || input === undefined) {
maxLength: number = 1000 return null;
): string | null { }
if (input === null || input === undefined) { const trimmed = input.trim();
return null; if (trimmed.length > maxLength) {
} throw new Error(`Input exceeds maximum length of ${maxLength}`);
const trimmed = input.trim(); }
if (trimmed.length > maxLength) { return trimmed;
throw new Error(`Input exceeds maximum length of ${maxLength}`);
}
return trimmed;
} }
export function sanitizeUrl(url: string | null | undefined): string | null { export function sanitizeUrl(url: string | null | undefined): string | null {
if (!url) { if (!url) {
return null; return null;
} }
return url.trim(); return url.trim();
} }
export function sanitizeText( export function sanitizeText(input: string | null | undefined, maxLength: number = 10000): string | null {
input: string | null | undefined, if (input === null || input === undefined) {
maxLength: number = 10000 return null;
): string | null { }
if (input === null || input === undefined) { const trimmed = input.trim();
return null; if (trimmed.length > maxLength) {
} throw new Error(`Text exceeds maximum length of ${maxLength}`);
const trimmed = input.trim(); }
if (trimmed.length > maxLength) { return trimmed;
throw new Error(`Text exceeds maximum length of ${maxLength}`);
}
return trimmed;
} }
export function sanitizePrice(price: string | null | undefined): number | null { export function sanitizePrice(price: string | null | undefined): number | null {
if (!price) { if (!price) {
return null; return null;
} }
const parsed = parseFloat(price.trim()); const parsed = parseFloat(price.trim());
if (isNaN(parsed)) { if (isNaN(parsed)) {
throw new Error('Invalid price format'); throw new Error('Invalid price format');
} }
if (parsed < 0) { if (parsed < 0) {
throw new Error('Price cannot be negative'); throw new Error('Price cannot be negative');
} }
return parsed; return parsed;
} }
export function sanitizeColor(color: string | null | undefined): string | null { export function sanitizeColor(color: string | null | undefined): string | null {
if (!color) { if (!color) {
return null; return null;
} }
return color.trim(); return color.trim();
} }
export function sanitizeCurrency(currency: string | null | undefined): string { export function sanitizeCurrency(currency: string | null | undefined): string {
if (!currency) { if (!currency) {
return 'DKK'; return 'DKK';
} }
return currency.trim().toUpperCase(); return currency.trim().toUpperCase();
} }
export function sanitizeUsername(username: string): string { export function sanitizeUsername(username: string): string {
const trimmed = username.trim().toLowerCase(); const trimmed = username.trim().toLowerCase();
if (trimmed.length < 3 || trimmed.length > 50) { if (trimmed.length < 3 || trimmed.length > 50) {
throw new Error('Username must be between 3 and 50 characters'); throw new Error('Username must be between 3 and 50 characters');
} }
return trimmed; return trimmed;
} }
export function sanitizeId(id: string | null | undefined): string { export function sanitizeId(id: string | null | undefined): string {
if (!id) { if (!id) {
throw new Error('ID is required'); throw new Error('ID is required');
} }
const trimmed = id.trim(); const trimmed = id.trim();
if (trimmed.length === 0 || trimmed.length > 100) { if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid ID'); throw new Error('Invalid ID');
} }
return trimmed; return trimmed;
} }
export function sanitizeToken(token: string | null | undefined): string { export function sanitizeToken(token: string | null | undefined): string {
if (!token) { if (!token) {
throw new Error('Token is required'); throw new Error('Token is required');
} }
const trimmed = token.trim(); const trimmed = token.trim();
if (trimmed.length === 0 || trimmed.length > 100) { if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid token'); throw new Error('Invalid token');
} }
return trimmed; return trimmed;
} }
// Zod schemas for type-safe form validation
import { z } from 'zod';
import { CURRENCIES } from '$lib/utils/currency';
export const currencySchema = z.enum(CURRENCIES);
export const itemSchema = z.object({
title: z.string().min(1, 'Title is required').max(255),
description: z.preprocess(val => (val === '' ? null : val), z.string().max(2000).optional().nullable()),
link: z.preprocess(val => (val === '' ? null : val), z.string().url('Invalid URL').optional().nullable()),
imageUrl: z.preprocess(val => (val === '' ? null : val), z.string().url('Invalid image URL').optional().nullable()),
price: z.coerce.number().nonnegative().optional().nullable(),
currency: currencySchema.default('DKK'),
color: z.preprocess(val => (val === '' ? null : val), z.string().optional().nullable()),
order: z.coerce.number().int().nonnegative().optional()
});
export const updateItemSchema = itemSchema.extend({
itemId: z.string().min(1, 'Item ID is required')
});
export const wishlistSchema = z.object({
title: z.string().min(1, 'Title is required').max(255),
description: z.string().max(2000).optional().nullable(),
color: z.string().optional().nullable(),
theme: z.string().optional().nullable().transform((val) => val || 'none'),
endDate: z.coerce.date().optional().nullable()
});
export const reorderSchema = z.array(
z.object({
id: z.string(),
order: z.number().int().nonnegative()
})
);
export type ItemFormData = z.infer<typeof itemSchema>;
export type UpdateItemFormData = z.infer<typeof updateItemSchema>;
export type WishlistFormData = z.infer<typeof wishlistSchema>;
+38 -38
View File
@@ -4,60 +4,60 @@ import type { Translation } from '$lib/i18n/translations/en';
const LANGUAGE_KEY = 'preferred-language'; const LANGUAGE_KEY = 'preferred-language';
function getStoredLanguage(): LanguageCode { function getStoredLanguage(): LanguageCode {
if (typeof window === 'undefined') return 'en'; if (typeof window === 'undefined') return 'en';
const stored = localStorage.getItem(LANGUAGE_KEY); const stored = localStorage.getItem(LANGUAGE_KEY);
if (stored && (stored === 'en' || stored === 'da')) { if (stored && (stored === 'en' || stored === 'da')) {
return stored as LanguageCode; return stored as LanguageCode;
} }
// Try to detect from browser // Try to detect from browser
const browserLang = navigator.language.toLowerCase(); const browserLang = navigator.language.toLowerCase();
if (browserLang.startsWith('da')) { if (browserLang.startsWith('da')) {
return 'da'; return 'da';
} }
return 'en'; return 'en';
} }
class LanguageStore { class LanguageStore {
private _current = $state<LanguageCode>(getStoredLanguage()); private _current = $state<LanguageCode>(getStoredLanguage());
get current(): LanguageCode { get current(): LanguageCode {
return this._current; return this._current;
} }
set current(value: LanguageCode) { set current(value: LanguageCode) {
this._current = value; this._current = value;
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.setItem(LANGUAGE_KEY, value); localStorage.setItem(LANGUAGE_KEY, value);
} }
} }
get t(): Translation { get t(): Translation {
return translations[this._current]; return translations[this._current];
} }
setLanguage(lang: LanguageCode) { setLanguage(lang: LanguageCode) {
this.current = lang; this.current = lang;
} }
} }
export const languageStore = new LanguageStore(); export const languageStore = new LanguageStore();
// Helper function to get nested translation value // Helper function to get nested translation value
export function t(path: string): string { export function t(path: string): string {
const keys = path.split('.'); const keys = path.split('.');
let value: unknown = languageStore.t; let value: any = languageStore.t;
for (const key of keys) { for (const key of keys) {
if (value && typeof value === 'object' && key in value) { if (value && typeof value === 'object' && key in value) {
value = value[key]; value = value[key];
} else { } else {
console.warn(`Translation key not found: ${path}`); console.warn(`Translation key not found: ${path}`);
return path; return path;
} }
} }
return typeof value === 'string' ? value : path; return typeof value === 'string' ? value : path;
} }
+50 -51
View File
@@ -4,67 +4,66 @@ type Theme = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark'; type ResolvedTheme = 'light' | 'dark';
class ThemeStore { class ThemeStore {
current = $state<Theme>('system'); current = $state<Theme>('system');
resolved = $state<ResolvedTheme>('light'); resolved = $state<ResolvedTheme>('light');
constructor() { constructor() {
if (browser) { if (browser) {
const stored = localStorage.getItem('theme') as Theme | null; const stored = localStorage.getItem('theme') as Theme | null;
this.current = stored || 'system'; this.current = stored || 'system';
this.applyTheme(); this.applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => { mediaQuery.addEventListener('change', () => {
if (this.current === 'system') { if (this.current === 'system') {
this.applyTheme(); this.applyTheme();
} }
}); });
} }
} }
private applyTheme() { private applyTheme() {
if (!browser) return; if (!browser) return;
const isDark = const isDark = this.current === 'dark' ||
this.current === 'dark' || (this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
this.resolved = isDark ? 'dark' : 'light'; this.resolved = isDark ? 'dark' : 'light';
if (isDark) { if (isDark) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
} }
getResolvedTheme(): ResolvedTheme { getResolvedTheme(): ResolvedTheme {
return this.resolved; return this.resolved;
} }
toggle() { toggle() {
// Cycle through: light -> dark -> system -> light // Cycle through: light -> dark -> system -> light
if (this.current === 'light') { if (this.current === 'light') {
this.current = 'dark'; this.current = 'dark';
} else if (this.current === 'dark') { } else if (this.current === 'dark') {
this.current = 'system'; this.current = 'system';
} else { } else {
this.current = 'light'; this.current = 'light';
} }
if (browser) { if (browser) {
localStorage.setItem('theme', this.current); localStorage.setItem('theme', this.current);
this.applyTheme(); this.applyTheme();
} }
} }
set(theme: Theme) { set(theme: Theme) {
this.current = theme; this.current = theme;
if (browser) { if (browser) {
localStorage.setItem('theme', this.current); localStorage.setItem('theme', this.current);
} }
this.applyTheme(); this.applyTheme();
} }
} }
export const themeStore = new ThemeStore(); export const themeStore = new ThemeStore();
+5 -5
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from "clsx";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T; export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T; export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>; export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null }; export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+8 -8
View File
@@ -2,19 +2,19 @@
* Convert hex color to rgba with transparency * Convert hex color to rgba with transparency
*/ */
export function hexToRgba(hex: string, alpha: number): string { export function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16); const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16); const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16); const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`; return `rgba(${r}, ${g}, ${b}, ${alpha})`;
} }
/** /**
* Generate card style string with color, transparency, and blur * Generate card style string with color, transparency, and blur
*/ */
export function getCardStyle(color: string | null, fallbackColor?: string | null): string { export function getCardStyle(color: string | null, fallbackColor?: string | null): string {
const activeColor = color || fallbackColor; const activeColor = color || fallbackColor;
if (!activeColor) return ''; if (!activeColor) return '';
const opacity = color ? 0.2 : 0.15; const opacity = color ? 0.2 : 0.15;
return `background-color: ${hexToRgba(activeColor, opacity)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`; return `background-color: ${hexToRgba(activeColor, opacity)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
} }
-41
View File
@@ -1,41 +0,0 @@
/**
* Currency utilities for formatting and displaying prices
*/
export const CURRENCIES = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'] as const;
export type Currency = (typeof CURRENCIES)[number];
export const currencySymbols: Record<Currency, string> = {
DKK: 'kr',
EUR: '€',
USD: '$',
SEK: 'kr',
NOK: 'kr',
GBP: '£'
};
/**
* Check if a currency symbol should be placed after the amount
*/
export function isPostfixCurrency(currency: Currency): boolean {
return ['DKK', 'SEK', 'NOK'].includes(currency);
}
/**
* Format a price with the appropriate currency symbol
*/
export function formatPrice(price: string | number | null, currency: Currency | null): string {
if (!price) return '';
const symbol = currency ? currencySymbols[currency] || currency : 'kr';
const amount = typeof price === 'string' ? parseFloat(price).toFixed(2) : price.toFixed(2);
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && isPostfixCurrency(currency)) {
return `${amount} ${symbol}`;
}
// For other currencies, put symbol before
return `${symbol}${amount}`;
}
+51 -49
View File
@@ -5,96 +5,98 @@
const LOCAL_WISHLISTS_KEY = 'local_wishlists'; const LOCAL_WISHLISTS_KEY = 'local_wishlists';
export interface LocalWishlist { export interface LocalWishlist {
ownerToken: string; ownerToken: string;
publicToken: string; publicToken: string;
title: string; title: string;
createdAt: string; createdAt: string;
isFavorite?: boolean; isFavorite?: boolean;
} }
/** /**
* Get all local wishlists from localStorage * Get all local wishlists from localStorage
*/ */
export function getLocalWishlists(): LocalWishlist[] { export function getLocalWishlists(): LocalWishlist[] {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
try { try {
const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY); const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY);
return stored ? JSON.parse(stored) : []; return stored ? JSON.parse(stored) : [];
} catch (error) { } catch (error) {
console.error('Failed to parse local wishlists:', error); console.error('Failed to parse local wishlists:', error);
return []; return [];
} }
} }
/** /**
* Add a wishlist to localStorage * Add a wishlist to localStorage
*/ */
export function addLocalWishlist(wishlist: LocalWishlist): void { export function addLocalWishlist(wishlist: LocalWishlist): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
const exists = wishlists.some((w) => w.ownerToken === wishlist.ownerToken); const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken);
if (exists) return; if (exists) return;
wishlists.push(wishlist); wishlists.push(wishlist);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists)); localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists));
} catch (error) { } catch (error) {
console.error('Failed to add local wishlist:', error); console.error('Failed to add local wishlist:', error);
} }
} }
/** /**
* Remove a wishlist from localStorage (forget it) * Remove a wishlist from localStorage (forget it)
*/ */
export function forgetLocalWishlist(ownerToken: string): void { export function forgetLocalWishlist(ownerToken: string): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
const filtered = wishlists.filter((w) => w.ownerToken !== ownerToken); const filtered = wishlists.filter(w => w.ownerToken !== ownerToken);
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered)); localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered));
} catch (error) { } catch (error) {
console.error('Failed to forget local wishlist:', error); console.error('Failed to forget local wishlist:', error);
} }
} }
/** /**
* Clear all local wishlists (e.g., when user claims all wishlists) * Clear all local wishlists (e.g., when user claims all wishlists)
*/ */
export function clearLocalWishlists(): void { export function clearLocalWishlists(): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
localStorage.removeItem(LOCAL_WISHLISTS_KEY); localStorage.removeItem(LOCAL_WISHLISTS_KEY);
} catch (error) { } catch (error) {
console.error('Failed to clear local wishlists:', error); console.error('Failed to clear local wishlists:', error);
} }
} }
/** /**
* Check if a wishlist is in local storage * Check if a wishlist is in local storage
*/ */
export function isLocalWishlist(ownerToken: string): boolean { export function isLocalWishlist(ownerToken: string): boolean {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
return wishlists.some((w) => w.ownerToken === ownerToken); return wishlists.some(w => w.ownerToken === ownerToken);
} }
/** /**
* Toggle favorite status for a local wishlist * Toggle favorite status for a local wishlist
*/ */
export function toggleLocalFavorite(ownerToken: string): void { export function toggleLocalFavorite(ownerToken: string): void {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
try { try {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
const updated = wishlists.map((w) => const updated = wishlists.map(w =>
w.ownerToken === ownerToken ? { ...w, isFavorite: !w.isFavorite } : w w.ownerToken === ownerToken
); ? { ...w, isFavorite: !w.isFavorite }
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated)); : w
} catch (error) { );
console.error('Failed to toggle local wishlist favorite:', error); localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
} } catch (error) {
console.error('Failed to toggle local wishlist favorite:', error);
}
} }
+15 -15
View File
@@ -3,31 +3,31 @@ import { themeStore } from '$lib/stores/theme.svelte';
export type ThemePattern = 'snow' | 'none'; export type ThemePattern = 'snow' | 'none';
export interface Theme { export interface Theme {
name: string; name: string;
pattern: ThemePattern; pattern: ThemePattern;
} }
export const AVAILABLE_THEMES: Record<string, Theme> = { export const AVAILABLE_THEMES: Record<string, Theme> = {
none: { none: {
name: 'None', name: 'None',
pattern: 'none' pattern: 'none'
}, },
waves: { waves: {
name: 'Snow', name: 'Snow',
pattern: 'snow' pattern: 'snow'
} }
}; };
export const DEFAULT_THEME = 'none'; export const DEFAULT_THEME = 'none';
export const PATTERN_OPACITY = 0.1; export const PATTERN_OPACITY = 0.1;
export function getTheme(themeName?: string | null): Theme { export function getTheme(themeName?: string | null): Theme {
if (!themeName || !AVAILABLE_THEMES[themeName]) { if (!themeName || !AVAILABLE_THEMES[themeName]) {
return AVAILABLE_THEMES[DEFAULT_THEME]; return AVAILABLE_THEMES[DEFAULT_THEME];
} }
return AVAILABLE_THEMES[themeName]; return AVAILABLE_THEMES[themeName];
} }
export function getPatternColor(customColor?: string): string { export function getPatternColor(customColor?: string): string {
return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000'); return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000');
} }
+32 -32
View File
@@ -3,59 +3,59 @@
*/ */
type UpdateField = { type UpdateField = {
[key: string]: string; [key: string]: string;
}; };
async function updateWishlist(field: UpdateField): Promise<boolean> { async function updateWishlist(field: UpdateField): Promise<boolean> {
const response = await fetch('?/updateWishlist', { const response = await fetch("?/updateWishlist", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded' "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams(field) body: new URLSearchParams(field),
}); });
if (!response.ok) { if (!response.ok) {
console.error(`Failed to update wishlist: ${Object.keys(field)[0]}`); console.error(`Failed to update wishlist: ${Object.keys(field)[0]}`);
return false; return false;
} }
return true; return true;
} }
export async function updateTitle(title: string): Promise<boolean> { export async function updateTitle(title: string): Promise<boolean> {
return updateWishlist({ title }); return updateWishlist({ title });
} }
export async function updateDescription(description: string | null): Promise<boolean> { export async function updateDescription(description: string | null): Promise<boolean> {
return updateWishlist({ description: description || '' }); return updateWishlist({ description: description || "" });
} }
export async function updateColor(color: string | null): Promise<boolean> { export async function updateColor(color: string | null): Promise<boolean> {
return updateWishlist({ color: color || '' }); return updateWishlist({ color: color || "" });
} }
export async function updateEndDate(endDate: string | null): Promise<boolean> { export async function updateEndDate(endDate: string | null): Promise<boolean> {
return updateWishlist({ endDate: endDate || '' }); return updateWishlist({ endDate: endDate || "" });
} }
export async function updateTheme(theme: string | null): Promise<boolean> { export async function updateTheme(theme: string | null): Promise<boolean> {
return updateWishlist({ theme: theme || 'none' }); return updateWishlist({ theme: theme || "none" });
} }
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> { export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
const response = await fetch('?/reorderItems', { const response = await fetch("?/reorderItems", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded' "Content-Type": "application/x-www-form-urlencoded",
}, },
body: new URLSearchParams({ body: new URLSearchParams({
items: JSON.stringify(items) items: JSON.stringify(items),
}) }),
}); });
if (!response.ok) { if (!response.ok) {
console.error('Failed to update item order'); console.error("Failed to update item order");
return false; return false;
} }
return true; return true;
} }
+5 -5
View File
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import '../app.css'; import '../app.css';
let { children } = $props(); let { children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="icon" href={favicon} /> <link rel="icon" href={favicon} />
</svelte:head> </svelte:head>
<div class="min-h-screen bg-slate-50 dark:bg-slate-950"> <div class="min-h-screen bg-slate-50 dark:bg-slate-950">
{@render children()} {@render children()}
</div> </div>
+5 -46
View File
@@ -1,49 +1,8 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { wishlists } from '$lib/db/schema';
import { createId } from '@paralleldrive/cuid2';
import { fail } from '@sveltejs/kit';
import { wishlistSchema } from '$lib/server/validation';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth(); const session = await event.locals.auth();
return { return {
session session
}; };
};
export const actions: Actions = {
createWishlist: async ({ request, locals }) => {
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
const result = wishlistSchema.safeParse(rawData);
if (!result.success) {
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
}
const session = await locals.auth();
const userId = session?.user?.id || null;
const ownerToken = createId();
const publicToken = createId();
const [wishlist] = await db
.insert(wishlists)
.values({
...result.data,
ownerToken,
publicToken,
userId
})
.returning();
return {
success: true,
ownerToken,
publicToken,
title: wishlist.title,
createdAt: wishlist.createdAt
};
}
}; };
+103 -104
View File
@@ -1,112 +1,111 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card, import { ThemeToggle } from '$lib/components/ui/theme-toggle';
CardContent, import { LanguageToggle } from '$lib/components/ui/language-toggle';
CardDescription, import { goto } from '$app/navigation';
CardHeader, import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
CardTitle import type { PageData } from './$types';
} from '$lib/components/ui/card'; import { languageStore } from '$lib/stores/language.svelte';
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; import { addLocalWishlist } from '$lib/utils/localWishlists';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import { goto } from '$app/navigation';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import type { PageData, ActionData } from './$types';
import { languageStore } from '$lib/stores/language.svelte';
import { addLocalWishlist } from '$lib/utils/localWishlists';
import { enhance } from '$app/forms';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data }: { data: PageData } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let title = $state(''); let title = $state('');
let description = $state(''); let description = $state('');
let color = $state<string | null>(null); let color = $state<string | null>(null);
let isCreating = $state(false); let isCreating = $state(false);
async function createWishlist() {
if (!title.trim()) return;
isCreating = true;
try {
const response = await fetch('/api/wishlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, color })
});
if (response.ok) {
const result = await response.json();
const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result;
// If user is not authenticated, save to localStorage
if (!data.session?.user) {
addLocalWishlist({
ownerToken,
publicToken,
title: wishlistTitle,
createdAt
});
}
goto(`/wishlist/${ownerToken}/edit`);
}
} catch (error) {
console.error('Failed to create wishlist:', error);
} finally {
isCreating = false;
}
}
</script> </script>
<div class="min-h-screen flex items-center justify-center p-4"> <div class="min-h-screen flex items-center justify-center p-4">
<Card class="w-full max-w-lg"> <Card class="w-full max-w-lg">
<CardHeader> <CardHeader>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<CardTitle class="text-3xl">{t.wishlist.createTitle}</CardTitle> <CardTitle class="text-3xl">{t.wishlist.createTitle}</CardTitle>
<CardDescription> <CardDescription>
{t.wishlist.createDescription} {t.wishlist.createDescription}
</CardDescription> </CardDescription>
</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">
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
<Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button> <Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button>
{#if !data.session?.user} {#if !data.session?.user}
<Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button> <Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button>
{/if} {/if}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form <form onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4">
method="POST" <div class="space-y-2">
action="?/createWishlist" <Label for="title">{t.form.wishlistTitle}</Label>
use:enhance={() => { <Input
isCreating = true; id="title"
return async ({ result, update }) => { bind:value={title}
if (result.type === 'success' && result.data) { placeholder={t.form.wishlistTitlePlaceholder}
const data = result.data as { ownerToken: string; publicToken: string; title: string; createdAt: string }; required
// If user is not authenticated, save to localStorage />
if (!data?.session?.user) { </div>
addLocalWishlist({ <div class="space-y-2">
ownerToken: data.ownerToken, <Label for="description">{t.form.descriptionOptional}</Label>
publicToken: data.publicToken, <Textarea
title: data.title, id="description"
createdAt: new Date(data.createdAt) bind:value={description}
}); placeholder={t.form.descriptionPlaceholder}
} rows={3}
goto(`/wishlist/${data.ownerToken}/edit`); />
} else { </div>
await update(); <div class="space-y-2">
isCreating = false; <div class="flex items-center justify-between">
} <Label for="color">{t.form.wishlistColor}</Label>
}; <ColorPicker bind:color={color} size="sm" />
}} </div>
class="space-y-4" </div>
> <Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
<div class="space-y-2"> {isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
<Label for="title">{t.form.wishlistTitle}</Label> </Button>
<Input </form>
id="title" </CardContent>
name="title" </Card>
bind:value={title}
placeholder={t.form.wishlistTitlePlaceholder}
required
/>
</div>
<div class="space-y-2">
<Label for="description">{t.form.descriptionOptional}</Label>
<Textarea
id="description"
name="description"
bind:value={description}
placeholder={t.form.descriptionPlaceholder}
rows={3}
/>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<Label for="color">{t.form.wishlistColor}</Label>
<ColorPicker bind:color size="sm" />
</div>
<input type="hidden" name="color" value={color || ''} />
</div>
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
</Button>
</form>
</CardContent>
</Card>
</div> </div>
+51 -51
View File
@@ -1,64 +1,64 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
function isValidImageUrl(url: string): boolean { function isValidImageUrl(url: string): boolean {
try { try {
const parsedUrl = new URL(url); const parsedUrl = new URL(url);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) { if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false; return false;
} }
const hostname = parsedUrl.hostname.toLowerCase(); const hostname = parsedUrl.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false; return false;
} }
return true; return true;
} catch { } catch {
return false; 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');
if (!imageUrl) { if (!imageUrl) {
return new Response('Image URL is required', { status: 400 }); return new Response('Image URL is required', { status: 400 });
} }
if (!isValidImageUrl(imageUrl)) { if (!isValidImageUrl(imageUrl)) {
return new Response('Invalid image URL', { status: 400 }); 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, {
headers: { headers: {
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', 'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9', 'Accept-Language': 'en-US,en;q=0.9',
Referer: new URL(imageUrl).origin, 'Referer': new URL(imageUrl).origin,
'Sec-Fetch-Dest': 'image', 'Sec-Fetch-Dest': 'image',
'Sec-Fetch-Mode': 'no-cors', 'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Site': 'cross-site' 'Sec-Fetch-Site': 'cross-site'
} }
}); });
if (!response.ok) { if (!response.ok) {
return new Response('Failed to fetch image', { status: response.status }); return new Response('Failed to fetch image', { status: response.status });
} }
const contentType = response.headers.get('content-type') || 'image/jpeg'; const contentType = response.headers.get('content-type') || 'image/jpeg';
const imageBuffer = await response.arrayBuffer(); const imageBuffer = await response.arrayBuffer();
// Return the image with appropriate headers // Return the image with appropriate headers
return new Response(imageBuffer, { return new Response(imageBuffer, {
headers: { headers: {
'Content-Type': contentType, 'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400', // Cache for 1 day 'Cache-Control': 'public, max-age=86400', // Cache for 1 day
'Access-Control-Allow-Origin': '*' 'Access-Control-Allow-Origin': '*'
} }
}); });
} catch (error) { } catch (error) {
console.error('Image proxy error:', error); console.error('Image proxy error:', error);
return new Response('Failed to proxy image', { status: 500 }); return new Response('Failed to proxy image', { status: 500 });
} }
}; };
+191 -213
View File
@@ -2,242 +2,220 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
function isValidUrl(urlString: string): boolean { function isValidUrl(urlString: string): boolean {
try { try {
const url = new URL(urlString); const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) { if (!['http:', 'https:'].includes(url.protocol)) {
return false; return false;
} }
const hostname = url.hostname.toLowerCase(); const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false; return false;
} }
return true; return true;
} catch { } catch {
return false; return false;
} }
} }
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json(); const { url } = await request.json();
if (!url) { if (!url) {
return json({ error: 'URL is required' }, { status: 400 }); return json({ error: 'URL is required' }, { status: 400 });
} }
if (!isValidUrl(url)) { if (!isValidUrl(url)) {
return json({ error: 'Invalid URL' }, { status: 400 }); return json({ error: 'Invalid URL' }, { status: 400 });
} }
try { try {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'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', 'Accept-Encoding': 'gzip, deflate, br',
'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'no-cache',
'Cache-Control': 'no-cache', 'Pragma': 'no-cache'
Pragma: 'no-cache' }
} });
});
if (!response.ok) { if (!response.ok) {
return json({ error: 'Failed to fetch URL' }, { status: 400 }); return json({ error: 'Failed to fetch URL' }, { status: 400 });
} }
const html = await response.text(); const html = await response.text();
const baseUrl = new URL(url); const baseUrl = new URL(url);
const origin = baseUrl.origin; const origin = baseUrl.origin;
const imageUrls: string[] = []; const imageUrls: string[] = [];
function toAbsoluteUrl(imgUrl: string): string { function toAbsoluteUrl(imgUrl: string): string {
if (imgUrl.startsWith('http')) { if (imgUrl.startsWith('http')) {
return imgUrl; return imgUrl;
} }
if (imgUrl.startsWith('//')) { if (imgUrl.startsWith('//')) {
return `https:${imgUrl}`; return `https:${imgUrl}`;
} }
if (imgUrl.startsWith('/')) { if (imgUrl.startsWith('/')) {
return `${origin}${imgUrl}`; return `${origin}${imgUrl}`;
} }
return `${origin}/${imgUrl}`; return `${origin}/${imgUrl}`;
} }
function isLikelyProductImage(url: string): boolean { function isLikelyProductImage(url: string): boolean {
const lower = url.toLowerCase(); const lower = url.toLowerCase();
const badPatterns = [ const badPatterns = [
'logo', 'logo', 'icon', 'sprite', 'favicon', 'banner', 'footer',
'icon', 'header', 'background', 'pattern', 'placeholder', 'thumbnail-small',
'sprite', 'btn', 'button', 'menu', 'nav', 'navigation', 'social',
'favicon', 'instagram', 'facebook', 'twitter', 'linkedin', 'pinterest'
'banner', ];
'footer', if (badPatterns.some(pattern => lower.includes(pattern))) {
'header', return false;
'background', }
'pattern', if (url.endsWith('.svg')) {
'placeholder', return false;
'thumbnail-small', }
'btn', if (lower.includes('data:image')) {
'button', return false;
'menu', }
'nav', if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
'navigation', return false;
'social', }
'instagram', return true;
'facebook', }
'twitter',
'linkedin',
'pinterest'
];
if (badPatterns.some((pattern) => lower.includes(pattern))) {
return false;
}
if (url.endsWith('.svg')) {
return false;
}
if (lower.includes('data:image')) {
return false;
}
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
return false;
}
return true;
}
let match; let match;
// Priority 1: OpenGraph and Twitter meta tags (main product image) // Priority 1: OpenGraph and Twitter meta tags (main product image)
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi; const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
const twitterImageRegex = const twitterImageRegex = /<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
while ((match = ogImageRegex.exec(html)) !== null) { while ((match = ogImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(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 (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 2: Look for JSON-LD structured data (very common in modern e-commerce) // Priority 2: Look for JSON-LD structured data (very common in modern e-commerce)
const jsonLdRegex = const jsonLdRegex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; while ((match = jsonLdRegex.exec(html)) !== null) {
while ((match = jsonLdRegex.exec(html)) !== null) { try {
try { const jsonStr = match[1];
const jsonStr = match[1]; const jsonData = JSON.parse(jsonStr);
const jsonData = JSON.parse(jsonStr);
function extractImages(obj: unknown, results: Set<string>) { function extractImages(obj: any, results: Set<string>) {
if (!obj || typeof obj !== 'object') return; if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
obj.forEach((item: unknown) => extractImages(item, results)); obj.forEach((item: any) => extractImages(item, results));
} else { } else {
const record = obj as Record<string, unknown>; for (const key in obj) {
for (const key in record) { if (key === 'image' || key === 'thumbnail' || key === 'url') {
if (key === 'image' || key === 'thumbnail' || key === 'url') { const val = obj[key];
const val = record[key]; if (typeof val === 'string') {
if (typeof val === 'string') { const url = toAbsoluteUrl(val);
const url = toAbsoluteUrl(val); if (isLikelyProductImage(url)) {
if (isLikelyProductImage(url)) { results.add(url);
results.add(url); }
} }
} if (Array.isArray(val)) {
if (Array.isArray(val)) { val.forEach((item: any) => {
val.forEach((item: unknown) => { if (typeof item === 'string') {
if (typeof item === 'string') { const url = toAbsoluteUrl(item);
const url = toAbsoluteUrl(item); if (isLikelyProductImage(url)) {
if (isLikelyProductImage(url)) { results.add(url);
results.add(url); }
} }
} });
}); }
} } else if (typeof obj[key] === 'object') {
} else if (typeof record[key] === 'object') { extractImages(obj[key], results);
extractImages(record[key], results); }
} }
} }
} }
}
const jsonImages = new Set<string>(); const jsonImages = new Set<string>();
extractImages(jsonData, jsonImages); extractImages(jsonData, jsonImages);
jsonImages.forEach((img) => { jsonImages.forEach(img => {
if (!imageUrls.includes(img)) { if (!imageUrls.includes(img)) {
imageUrls.push(img); imageUrls.push(img);
} }
}); });
} catch { } catch {
// JSON parsing failed, continue // JSON parsing failed, continue
} }
} }
// Priority 3: Look for data-image attributes (common in React/SPA) // Priority 3: Look for data-image attributes (common in React/SPA)
const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi; const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
while ((match = dataImageRegex.exec(html)) !== null) { while ((match = dataImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !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; 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];
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]; return parts[0];
}); });
for (const srcsetUrl of srcsetUrls) { for (const srcsetUrl of srcsetUrls) {
const url = toAbsoluteUrl(srcsetUrl); const url = toAbsoluteUrl(srcsetUrl);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
} }
// Priority 5: data-src attributes (lazy loaded) // Priority 5: data-src attributes (lazy loaded)
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi; const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
while ((match = dataSrcRegex.exec(html)) !== null) { while ((match = dataSrcRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 6: Regular img src attributes // Priority 6: Regular img src attributes
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi; 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 (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 7: Background images in style attributes (common in some e-commerce) // Priority 7: Background images in style attributes (common in some e-commerce)
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi; const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi;
while ((match = bgImageRegex.exec(html)) !== null) { while ((match = bgImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) { if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Final filtering: remove very long URLs and duplicates // Final filtering: remove very long URLs and duplicates
const finalImages = [...new Set(imageUrls)].filter((url) => { const finalImages = [...new Set(imageUrls)].filter(url => {
return url.length < 2000 && isLikelyProductImage(url); return url.length < 2000 && isLikelyProductImage(url);
}); });
return json({ images: finalImages.slice(0, 30) }); return json({ images: finalImages.slice(0, 30) });
} catch { } catch (error) {
return json({ error: 'Failed to scrape images' }, { status: 500 }); return json({ error: 'Failed to scrape images' }, { status: 500 });
} }
}; };
+28 -25
View File
@@ -1,35 +1,38 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists } from '$lib/db/schema'; import { wishlists } from '$lib/server/schema';
import { eq, or } from 'drizzle-orm'; import { eq, or } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const { token } = params; const { token } = params;
// Find wishlist by either ownerToken or publicToken // Find wishlist by either ownerToken or publicToken
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
where: or(eq(wishlists.ownerToken, token), eq(wishlists.publicToken, token)), where: or(
with: { eq(wishlists.ownerToken, token),
items: { eq(wishlists.publicToken, token)
orderBy: (items, { asc }) => [asc(items.order)] ),
} with: {
} items: {
}); orderBy: (items, { asc }) => [asc(items.order)]
}
}
});
if (!wishlist) { if (!wishlist) {
return json({ error: 'Wishlist not found' }, { status: 404 }); return json({ error: 'Wishlist not found' }, { status: 404 });
} }
// Return only the necessary fields // Return only the necessary fields
return json({ return json({
id: wishlist.id, id: wishlist.id,
title: wishlist.title, title: wishlist.title,
ownerToken: wishlist.ownerToken, ownerToken: wishlist.ownerToken,
publicToken: wishlist.publicToken, publicToken: wishlist.publicToken,
createdAt: wishlist.createdAt, createdAt: wishlist.createdAt,
theme: wishlist.theme, theme: wishlist.theme,
color: wishlist.color, color: wishlist.color,
items: wishlist.items || [] items: wishlist.items || []
}); });
}; };
+52
View File
@@ -0,0 +1,52 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { wishlists } from '$lib/server/schema';
import { createId } from '@paralleldrive/cuid2';
import { sanitizeString, sanitizeColor } from '$lib/server/validation';
export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json();
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 });
}
const session = await locals.auth();
const userId = session?.user?.id || null;
const ownerToken = createId();
const publicToken = createId();
const [wishlist] = await db
.insert(wishlists)
.values({
title,
description,
color,
ownerToken,
publicToken,
userId
})
.returning();
return json({
ownerToken,
publicToken,
id: wishlist.id,
title: wishlist.title,
createdAt: wishlist.createdAt
});
};
+150 -154
View File
@@ -1,198 +1,194 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, savedWishlists, users } from '$lib/db/schema'; import { wishlists, savedWishlists, users } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth(); const session = await event.locals.auth();
// Allow anonymous users to access dashboard for local wishlists // Allow anonymous users to access dashboard for local wishlists
if (!session?.user?.id) { if (!session?.user?.id) {
return { return {
user: null, user: null,
wishlists: [], wishlists: [],
savedWishlists: [], savedWishlists: [],
isAuthenticated: false isAuthenticated: false
}; };
} }
// Fetch user with theme // Fetch user with theme
const user = await db.query.users.findFirst({ const user = await db.query.users.findFirst({
where: eq(users.id, session.user.id) where: eq(users.id, session.user.id)
}); });
const userWishlists = await db.query.wishlists.findMany({ const userWishlists = await db.query.wishlists.findMany({
where: eq(wishlists.userId, session.user.id), where: eq(wishlists.userId, session.user.id),
with: { with: {
items: { items: {
orderBy: (items, { asc }) => [asc(items.order)] orderBy: (items, { asc }) => [asc(items.order)]
}, },
user: true user: true
}, },
orderBy: (wishlists, { desc }) => [desc(wishlists.createdAt)] orderBy: (wishlists, { desc }) => [desc(wishlists.createdAt)]
}); });
const saved = await db.query.savedWishlists.findMany({ const saved = await db.query.savedWishlists.findMany({
where: eq(savedWishlists.userId, session.user.id), where: eq(savedWishlists.userId, session.user.id),
with: { with: {
wishlist: { wishlist: {
with: { with: {
items: { items: {
orderBy: (items, { asc }) => [asc(items.order)] orderBy: (items, { asc }) => [asc(items.order)]
}, },
user: true user: true
} }
} }
}, },
orderBy: (savedWishlists, { desc }) => [desc(savedWishlists.createdAt)] orderBy: (savedWishlists, { desc }) => [desc(savedWishlists.createdAt)]
}); });
// Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist) // Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist)
// This ensures users only see ownerToken if they claimed via edit link // This ensures users only see ownerToken if they claimed via edit link
const savedWithAccess = saved.map((s) => ({ const savedWithAccess = saved.map(s => ({
...s, ...s,
wishlist: s.wishlist wishlist: s.wishlist ? {
? { ...s.wishlist,
...s.wishlist, // Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves) ownerToken: s.ownerToken,
ownerToken: s.ownerToken, // Keep publicToken as-is for viewing
// Keep publicToken as-is for viewing publicToken: s.wishlist.publicToken
publicToken: s.wishlist.publicToken } : null
} }));
: null
}));
return { return {
user: user, user: user,
wishlists: userWishlists, wishlists: userWishlists,
savedWishlists: savedWithAccess, savedWishlists: savedWithAccess,
isAuthenticated: true isAuthenticated: true
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
toggleFavorite: async ({ request, locals }) => { toggleFavorite: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); throw redirect(303, '/signin');
} }
const formData = await request.formData(); const formData = await request.formData();
const wishlistId = formData.get('wishlistId') as string; const wishlistId = formData.get('wishlistId') as string;
const isFavorite = formData.get('isFavorite') === 'true'; const isFavorite = formData.get('isFavorite') === 'true';
if (!wishlistId) { if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' }; return { success: false, error: 'Wishlist ID is required' };
} }
await db await db.update(wishlists)
.update(wishlists) .set({ isFavorite: !isFavorite, updatedAt: new Date() })
.set({ isFavorite: !isFavorite, updatedAt: new Date() }) .where(eq(wishlists.id, wishlistId));
.where(eq(wishlists.id, wishlistId));
return { success: true }; return { success: true };
}, },
toggleSavedFavorite: async ({ request, locals }) => { toggleSavedFavorite: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); throw redirect(303, '/signin');
} }
const formData = await request.formData(); const formData = await request.formData();
const savedWishlistId = formData.get('savedWishlistId') as string; const savedWishlistId = formData.get('savedWishlistId') as string;
const isFavorite = formData.get('isFavorite') === 'true'; const isFavorite = formData.get('isFavorite') === 'true';
if (!savedWishlistId) { if (!savedWishlistId) {
return { success: false, error: 'Saved wishlist ID is required' }; return { success: false, error: 'Saved wishlist ID is required' };
} }
await db await db.update(savedWishlists)
.update(savedWishlists) .set({ isFavorite: !isFavorite })
.set({ isFavorite: !isFavorite }) .where(eq(savedWishlists.id, savedWishlistId));
.where(eq(savedWishlists.id, savedWishlistId));
return { success: true }; return { success: true };
}, },
unsaveWishlist: async ({ request, locals }) => { unsaveWishlist: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); throw redirect(303, '/signin');
} }
const formData = await request.formData(); const formData = await request.formData();
const savedWishlistId = formData.get('savedWishlistId') as string; const savedWishlistId = formData.get('savedWishlistId') as string;
if (!savedWishlistId) { if (!savedWishlistId) {
return { success: false, error: 'Saved wishlist ID is required' }; return { success: false, error: 'Saved wishlist ID is required' };
} }
await db await db.delete(savedWishlists)
.delete(savedWishlists) .where(and(
.where( eq(savedWishlists.id, savedWishlistId),
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id)) eq(savedWishlists.userId, session.user.id)
); ));
return { success: true }; return { success: true };
}, },
deleteWishlist: async ({ request, locals }) => { deleteWishlist: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); throw redirect(303, '/signin');
} }
const formData = await request.formData(); const formData = await request.formData();
const wishlistId = formData.get('wishlistId') as string; const wishlistId = formData.get('wishlistId') as string;
if (!wishlistId) { if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' }; return { success: false, error: 'Wishlist ID is required' };
} }
await db await db.delete(wishlists)
.delete(wishlists) .where(and(
.where(and(eq(wishlists.id, wishlistId), eq(wishlists.userId, session.user.id))); eq(wishlists.id, wishlistId),
eq(wishlists.userId, session.user.id)
));
return { success: true }; return { success: true };
}, },
updateDashboardTheme: async ({ request, locals }) => { updateDashboardTheme: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); throw redirect(303, '/signin');
} }
const formData = await request.formData(); const formData = await request.formData();
const theme = formData.get('theme') as string; const theme = formData.get('theme') as string;
if (!theme) { if (!theme) {
return { success: false, error: 'Theme is required' }; return { success: false, error: 'Theme is required' };
} }
await db await db.update(users)
.update(users) .set({ dashboardTheme: theme, updatedAt: new Date() })
.set({ dashboardTheme: theme, updatedAt: new Date() }) .where(eq(users.id, session.user.id));
.where(eq(users.id, session.user.id));
return { success: true }; return { success: true };
}, },
updateDashboardColor: async ({ request, locals }) => { updateDashboardColor: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw redirect(303, '/signin'); throw redirect(303, '/signin');
} }
const formData = await request.formData(); const formData = await request.formData();
const color = formData.get('color') as string | null; const color = formData.get('color') as string | null;
await db await db.update(users)
.update(users) .set({ dashboardColor: color, updatedAt: new Date() })
.set({ dashboardColor: color, updatedAt: new Date() }) .where(eq(users.id, session.user.id));
.where(eq(users.id, session.user.id));
return { success: true }; return { success: true };
} }
}; };
+234 -258
View File
@@ -1,279 +1,255 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import type { PageData } from './$types'; import type { PageData } from './$types';
import PageContainer from '$lib/components/layout/PageContainer.svelte'; import PageContainer from '$lib/components/layout/PageContainer.svelte';
import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte'; import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte'; import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte'; import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte';
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import { Star } from '@lucide/svelte'; import { Star } from 'lucide-svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
function getInitialTheme() { function getInitialTheme() {
if (data.isAuthenticated) { if (data.isAuthenticated) {
return data.user?.dashboardTheme || 'none'; return data.user?.dashboardTheme || 'none';
} else { } else {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardTheme') || 'none'; return localStorage.getItem('dashboardTheme') || 'none';
} }
return 'none'; return 'none';
} }
} }
function getInitialColor() { function getInitialColor() {
if (data.isAuthenticated) { if (data.isAuthenticated) {
return data.user?.dashboardColor || null; return data.user?.dashboardColor || null;
} else { } else {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardColor') || null; return localStorage.getItem('dashboardColor') || null;
} }
return null; return null;
} }
} }
let currentTheme = $state(getInitialTheme()); let currentTheme = $state(getInitialTheme());
let currentColor = $state(getInitialColor()); let currentColor = $state(getInitialColor());
function handleThemeUpdate(theme: string | null) { function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none'; currentTheme = theme || 'none';
if (!data.isAuthenticated && typeof window !== 'undefined') { if (!data.isAuthenticated && typeof window !== 'undefined') {
localStorage.setItem('dashboardTheme', currentTheme); localStorage.setItem('dashboardTheme', currentTheme);
} }
} }
function handleColorUpdate(color: string | null) { function handleColorUpdate(color: string | null) {
currentColor = color; currentColor = color;
if (!data.isAuthenticated && typeof window !== 'undefined') { if (!data.isAuthenticated && typeof window !== 'undefined') {
if (color) { if (color) {
localStorage.setItem('dashboardColor', color); localStorage.setItem('dashboardColor', color);
} else { } else {
localStorage.removeItem('dashboardColor'); localStorage.removeItem('dashboardColor');
} }
} }
} }
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
const myWishlists = $derived(() => data.wishlists || []); const myWishlists = $derived(() => data.wishlists || []);
const claimedWishlists = $derived(() => { const claimedWishlists = $derived(() => {
return (data.savedWishlists || []) return (data.savedWishlists || [])
.filter((saved) => saved.wishlist?.ownerToken) .filter(saved => saved.wishlist?.ownerToken)
.map((saved) => ({ .map(saved => ({
...saved.wishlist, ...saved.wishlist,
isFavorite: saved.isFavorite, isFavorite: saved.isFavorite,
isClaimed: true, isClaimed: true,
savedId: saved.id savedId: saved.id
})); }));
}); });
const savedWishlists = $derived(() => { const savedWishlists = $derived(() => {
return (data.savedWishlists || []).filter((saved) => !saved.wishlist?.ownerToken); return (data.savedWishlists || []).filter(saved => !saved.wishlist?.ownerToken);
}); });
</script> </script>
<PageContainer theme={currentTheme} themeColor={currentColor}> <PageContainer theme={currentTheme} themeColor={currentColor}>
<DashboardHeader <DashboardHeader
userName={data.user?.name} userName={data.user?.name}
userEmail={data.user?.email} userEmail={data.user?.email}
dashboardTheme={currentTheme} dashboardTheme={currentTheme}
dashboardColor={currentColor} dashboardColor={currentColor}
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
onThemeUpdate={handleThemeUpdate} onThemeUpdate={handleThemeUpdate}
onColorUpdate={handleColorUpdate} onColorUpdate={handleColorUpdate}
/> />
{#if data.isAuthenticated} {#if data.isAuthenticated}
<WishlistSection <WishlistSection
title={t.dashboard.myWishlists} title={t.dashboard.myWishlists}
description={t.dashboard.myWishlistsDescription} description={t.dashboard.myWishlistsDescription}
items={myWishlists()} items={myWishlists()}
emptyMessage={t.dashboard.emptyWishlists} emptyMessage={t.dashboard.emptyWishlists}
emptyActionLabel={t.dashboard.emptyWishlistsAction} emptyActionLabel={t.dashboard.emptyWishlistsAction}
emptyActionHref="/" emptyActionHref="/"
showCreateButton={true} showCreateButton={true}
fallbackColor={currentColor} fallbackColor={currentColor}
fallbackTheme={currentTheme} fallbackTheme={currentTheme}
> >
{#snippet actions(wishlist, unlocked)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form <form method="POST" action="?/toggleFavorite" use:enhance={() => {
method="POST" return async ({ update }) => {
action="?/toggleFavorite" await update({ reset: false });
use:enhance={() => { };
return async ({ update }) => { }}>
await update({ reset: false }); <input type="hidden" name="wishlistId" value={wishlist.id} />
}; <input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
}} <Button type="submit" size="sm" variant="outline">
> <Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
<input type="hidden" name="wishlistId" value={wishlist.id} /> </Button>
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} /> </form>
<Button type="submit" size="sm" variant="outline"> <Button
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> size="sm"
</Button> onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
</form> >
<Button {t.dashboard.manage}
size="sm" </Button>
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)} <Button
> size="sm"
{t.dashboard.manage} variant="outline"
</Button> onclick={() => {
<Button navigator.clipboard.writeText(
size="sm" `${window.location.origin}/wishlist/${wishlist.publicToken}`
variant="outline" );
onclick={() => { }}
navigator.clipboard.writeText( >
`${window.location.origin}/wishlist/${wishlist.publicToken}` {t.dashboard.copyLink}
); </Button>
}} {#if unlocked}
> <form method="POST" action="?/deleteWishlist" use:enhance={() => {
{t.dashboard.copyLink} return async ({ update }) => {
</Button> await update({ reset: false });
{#if unlocked} };
<form }}>
method="POST" <input type="hidden" name="wishlistId" value={wishlist.id} />
action="?/deleteWishlist" <Button type="submit" size="sm" variant="destructive">
use:enhance={() => { {t.dashboard.delete}
return async ({ update }) => { </Button>
await update({ reset: false }); </form>
}; {/if}
}} </div>
> {/snippet}
<input type="hidden" name="wishlistId" value={wishlist.id} /> </WishlistSection>
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.delete}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<LocalWishlistsSection <LocalWishlistsSection
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor} fallbackColor={currentColor}
fallbackTheme={currentTheme} fallbackTheme={currentTheme}
/> />
<WishlistSection <WishlistSection
title={t.dashboard.claimedWishlists} title={t.dashboard.claimedWishlists}
description={t.dashboard.claimedWishlistsDescription} description={t.dashboard.claimedWishlistsDescription}
items={claimedWishlists()} items={claimedWishlists()}
emptyMessage={t.dashboard.emptyClaimedWishlists} emptyMessage={t.dashboard.emptyClaimedWishlists}
emptyDescription={t.dashboard.emptyClaimedWishlistsDescription} emptyDescription={t.dashboard.emptyClaimedWishlistsDescription}
hideIfEmpty={true} hideIfEmpty={true}
fallbackColor={currentColor} fallbackColor={currentColor}
fallbackTheme={currentTheme} fallbackTheme={currentTheme}
> >
{#snippet actions(wishlist, unlocked)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form <form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
method="POST" return async ({ update }) => {
action="?/toggleSavedFavorite" await update({ reset: false });
use:enhance={() => { };
return async ({ update }) => { }}>
await update({ reset: false }); <input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
}; <input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
}} <Button type="submit" size="sm" variant="outline">
> <Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} /> </Button>
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} /> </form>
<Button type="submit" size="sm" variant="outline"> <Button
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> size="sm"
</Button> onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
</form> >
<Button {t.dashboard.manage}
size="sm" </Button>
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)} <Button
> size="sm"
{t.dashboard.manage} variant="outline"
</Button> onclick={() => {
<Button navigator.clipboard.writeText(
size="sm" `${window.location.origin}/wishlist/${wishlist.publicToken}`
variant="outline" );
onclick={() => { }}
navigator.clipboard.writeText( >
`${window.location.origin}/wishlist/${wishlist.publicToken}` {t.dashboard.copyLink}
); </Button>
}} {#if unlocked}
> <form method="POST" action="?/unsaveWishlist" use:enhance={() => {
{t.dashboard.copyLink} return async ({ update }) => {
</Button> await update({ reset: false });
{#if unlocked} };
<form }}>
method="POST" <input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
action="?/unsaveWishlist" <Button type="submit" size="sm" variant="destructive">
use:enhance={() => { {t.dashboard.unclaim}
return async ({ update }) => { </Button>
await update({ reset: false }); </form>
}; {/if}
}} </div>
> {/snippet}
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} /> </WishlistSection>
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.unclaim}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<WishlistSection <WishlistSection
title={t.dashboard.savedWishlists} title={t.dashboard.savedWishlists}
description={t.dashboard.savedWishlistsDescription} description={t.dashboard.savedWishlistsDescription}
items={savedWishlists()} items={savedWishlists()}
emptyMessage={t.dashboard.emptySavedWishlists} emptyMessage={t.dashboard.emptySavedWishlists}
emptyDescription={t.dashboard.emptySavedWishlistsDescription} emptyDescription={t.dashboard.emptySavedWishlistsDescription}
fallbackColor={currentColor} fallbackColor={currentColor}
fallbackTheme={currentTheme} fallbackTheme={currentTheme}
hideIfEmpty={true} hideIfEmpty={true}
> >
{#snippet actions(saved, unlocked)} {#snippet actions(saved, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form <form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
method="POST" return async ({ update }) => {
action="?/toggleSavedFavorite" await update({ reset: false });
use:enhance={() => { };
return async ({ update }) => { }}>
await update({ reset: false }); <input type="hidden" name="savedWishlistId" value={saved.id} />
}; <input type="hidden" name="isFavorite" value={saved.isFavorite} />
}} <Button type="submit" size="sm" variant="outline">
> <Star class={saved.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
<input type="hidden" name="savedWishlistId" value={saved.id} /> </Button>
<input type="hidden" name="isFavorite" value={saved.isFavorite} /> </form>
<Button type="submit" size="sm" variant="outline"> <Button
<Star class={saved.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> size="sm"
</Button> onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)}
</form> >
<Button {t.dashboard.viewWishlist}
size="sm" </Button>
onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)} {#if unlocked}
> <form method="POST" action="?/unsaveWishlist" use:enhance={() => {
{t.dashboard.viewWishlist} return async ({ update }) => {
</Button> await update({ reset: false });
{#if unlocked} };
<form }}>
method="POST" <input type="hidden" name="savedWishlistId" value={saved.id} />
action="?/unsaveWishlist" <Button type="submit" size="sm" variant="destructive">
use:enhance={() => { {t.dashboard.unsave}
return async ({ update }) => { </Button>
await update({ reset: false }); </form>
}; {/if}
}} </div>
> {/snippet}
<input type="hidden" name="savedWishlistId" value={saved.id} /> </WishlistSection>
<Button type="submit" size="sm" variant="destructive"> {/if}
{t.dashboard.unsave}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
{/if}
</PageContainer> </PageContainer>
+15 -15
View File
@@ -2,23 +2,23 @@ import type { PageServerLoad } from './$types';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
export const load: PageServerLoad = async ({ url }) => { export const load: PageServerLoad = async ({ url }) => {
const registered = url.searchParams.get('registered'); const registered = url.searchParams.get('registered');
const error = url.searchParams.get('error'); const error = url.searchParams.get('error');
// Determine which OAuth providers are available // Determine which OAuth providers are available
const oauthProviders = []; const oauthProviders = [];
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
oauthProviders.push({ id: 'google', name: 'Google' }); oauthProviders.push({ id: 'google', name: 'Google' });
} }
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) { if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
oauthProviders.push({ id: 'authentik', name: 'Authentik' }); oauthProviders.push({ id: 'authentik', name: 'Authentik' });
} }
return { return {
registered: registered === 'true', registered: registered === 'true',
error: error, error: error,
oauthProviders oauthProviders
}; };
}; };
+89 -99
View File
@@ -1,117 +1,107 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card, import { Input } from '$lib/components/ui/input';
CardContent, import { Label } from '$lib/components/ui/label';
CardDescription, import { ThemeToggle } from '$lib/components/ui/theme-toggle';
CardHeader, import { LanguageToggle } from '$lib/components/ui/language-toggle';
CardTitle import type { PageData } from './$types';
} from '$lib/components/ui/card'; import { signIn } from '@auth/sveltekit/client';
import { Input } from '$lib/components/ui/input'; import { languageStore } from '$lib/stores/language.svelte';
import { Label } from '$lib/components/ui/label';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let isSubmitting = $state(false); let isSubmitting = $state(false);
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
isSubmitting = true; isSubmitting = true;
const formData = new FormData(e.target as HTMLFormElement); const formData = new FormData(e.target as HTMLFormElement);
const username = formData.get('username') as string; const username = formData.get('username') as string;
const password = formData.get('password') as string; const password = formData.get('password') as string;
try { try {
await signIn('credentials', { await signIn('credentials', {
username, username,
password, password,
callbackUrl: '/dashboard' callbackUrl: '/dashboard'
}); });
} catch (error) { } catch (error) {
console.error('Sign in error:', error); console.error('Sign in error:', error);
} finally { } finally {
isSubmitting = false; isSubmitting = false;
} }
} }
</script> </script>
<div class="min-h-screen flex items-center justify-center p-4"> <div class="min-h-screen flex items-center justify-center p-4">
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2"> <div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
<Card class="w-full max-w-md"> <Card class="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle class="text-2xl">{t.auth.welcomeBack}</CardTitle> <CardTitle class="text-2xl">{t.auth.welcomeBack}</CardTitle>
<CardDescription>{t.auth.signInPrompt}</CardDescription> <CardDescription>{t.auth.signInPrompt}</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
{#if data.registered} {#if data.registered}
<div <div class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded">
class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded" Account created successfully! Please sign in.
> </div>
Account created successfully! Please sign in. {/if}
</div>
{/if}
{#if data.error} {#if data.error}
<div <div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded" Invalid username or password
> </div>
Invalid username or password {/if}
</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-4"> <form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="username">{t.form.username}</Label> <Label for="username">{t.form.username}</Label>
<Input id="username" name="username" type="text" required /> <Input id="username" name="username" type="text" required />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password">{t.form.password}</Label> <Label for="password">{t.form.password}</Label>
<Input id="password" name="password" type="password" required /> <Input id="password" name="password" type="password" required />
</div> </div>
<Button type="submit" class="w-full" disabled={isSubmitting}> <Button type="submit" class="w-full" disabled={isSubmitting}>
{isSubmitting ? t.auth.signingIn : t.auth.signIn} {isSubmitting ? t.auth.signingIn : t.auth.signIn}
</Button> </Button>
</form> </form>
{#if data.oauthProviders.length > 0} {#if data.oauthProviders.length > 0}
<div class="relative"> <div class="relative">
<div class="absolute inset-0 flex items-center"> <div class="absolute inset-0 flex items-center">
<span class="w-full border-t"></span> <span class="w-full border-t"></span>
</div> </div>
<div class="relative flex justify-center text-xs uppercase"> <div class="relative flex justify-center text-xs uppercase">
<span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span> <span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span>
</div> </div>
</div> </div>
{#each data.oauthProviders as provider (provider.id)} {#each data.oauthProviders as provider}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
class="w-full" class="w-full"
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })} onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
> >
{t.auth.signIn} with {provider.name} {t.auth.signIn} with {provider.name}
</Button> </Button>
{/each} {/each}
{/if} {/if}
<div class="text-center text-sm text-muted-foreground"> <div class="text-center text-sm text-muted-foreground">
{t.auth.dontHaveAccount} {t.auth.dontHaveAccount}
<a href="/signup" class="text-primary hover:underline">{t.auth.signUp}</a> <a href="/signup" class="text-primary hover:underline">{t.auth.signUp}</a>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+49 -50
View File
@@ -1,76 +1,75 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { users, type NewUser } from '$lib/db/schema'; 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'; 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
const oauthProviders = []; const oauthProviders = [];
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) { if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
oauthProviders.push({ id: 'google', name: 'Google' }); oauthProviders.push({ id: 'google', name: 'Google' });
} }
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) { if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
oauthProviders.push({ id: 'authentik', name: 'Authentik' }); oauthProviders.push({ id: 'authentik', name: 'Authentik' });
} }
return { return {
oauthProviders oauthProviders
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
default: async ({ request }) => { default: async ({ request }) => {
const formData = await request.formData(); const formData = await request.formData();
const name = formData.get('name') as string; const name = formData.get('name') as string;
const username = formData.get('username') as string; const username = formData.get('username') as string;
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;
let sanitizedUsername: string; let sanitizedUsername: string;
let sanitizedName: string | null; let sanitizedName: string | null;
try { try {
sanitizedName = sanitizeString(name, 100); sanitizedName = sanitizeString(name, 100);
sanitizedUsername = sanitizeUsername(username); sanitizedUsername = sanitizeUsername(username);
} catch { } catch (error) {
return fail(400, { error: 'Invalid input', name, username }); return fail(400, { error: 'Invalid input', name, username });
} }
if (!sanitizedName) { if (!sanitizedName) {
return fail(400, { error: 'Name is required', name, username }); return fail(400, { error: 'Name is required', name, username });
} }
if (!password || password.length < 8) { if (!password || password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', name, username }); return fail(400, { error: 'Password must be at least 8 characters', name, username });
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match', name, username }); return fail(400, { error: 'Passwords do not match', name, username });
} }
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.username, sanitizedUsername) 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, 14); const hashedPassword = await bcrypt.hash(password, 14);
const newUser: NewUser = { await db.insert(users).values({
name: sanitizedName, name: sanitizedName,
username: sanitizedUsername, username: sanitizedUsername,
password: hashedPassword password: hashedPassword
}; });
await db.insert(users).values(newUser);
throw redirect(303, '/signin?registered=true'); throw redirect(303, '/signin?registered=true');
} }
}; };
+71 -85
View File
@@ -1,100 +1,86 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card, import { Input } from '$lib/components/ui/input';
CardContent, import { Label } from '$lib/components/ui/label';
CardDescription, import { ThemeToggle } from '$lib/components/ui/theme-toggle';
CardHeader, import { LanguageToggle } from '$lib/components/ui/language-toggle';
CardTitle import type { ActionData, PageData } from './$types';
} from '$lib/components/ui/card'; import { signIn } from '@auth/sveltekit/client';
import { Input } from '$lib/components/ui/input'; import { languageStore } from '$lib/stores/language.svelte';
import { Label } from '$lib/components/ui/label';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { ActionData, PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
let { form, data }: { form: ActionData; data: PageData } = $props(); let { form, data }: { form: ActionData; data: PageData } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
</script> </script>
<div class="min-h-screen flex items-center justify-center p-4"> <div class="min-h-screen flex items-center justify-center p-4">
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2"> <div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
</div> </div>
<Card class="w-full max-w-md"> <Card class="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle class="text-2xl">{t.auth.createAccount}</CardTitle> <CardTitle class="text-2xl">{t.auth.createAccount}</CardTitle>
<CardDescription>{t.auth.signUpPrompt}</CardDescription> <CardDescription>{t.auth.signUpPrompt}</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
{#if form?.error} {#if form?.error}
<div <div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded" {form.error}
> </div>
{form.error} {/if}
</div>
{/if}
<form method="POST" class="space-y-4"> <form method="POST" class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="name">{t.form.name}</Label> <Label for="name">{t.form.name}</Label>
<Input id="name" name="name" type="text" required value={form?.name || ''} /> <Input id="name" name="name" type="text" required value={form?.name || ''} />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="username">{t.form.username}</Label> <Label for="username">{t.form.username}</Label>
<Input id="username" name="username" type="text" required value={form?.username || ''} /> <Input id="username" name="username" type="text" required value={form?.username || ''} />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password">{t.form.password}</Label> <Label for="password">{t.form.password}</Label>
<Input id="password" name="password" type="password" required minlength={8} /> <Input id="password" name="password" type="password" required minlength={8} />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="confirmPassword">{t.form.confirmPassword}</Label> <Label for="confirmPassword">{t.form.confirmPassword}</Label>
<Input <Input id="confirmPassword" name="confirmPassword" type="password" required minlength={8} />
id="confirmPassword" </div>
name="confirmPassword"
type="password"
required
minlength={8}
/>
</div>
<Button type="submit" class="w-full">{t.auth.signUp}</Button> <Button type="submit" class="w-full">{t.auth.signUp}</Button>
</form> </form>
{#if data.oauthProviders.length > 0} {#if data.oauthProviders.length > 0}
<div class="relative"> <div class="relative">
<div class="absolute inset-0 flex items-center"> <div class="absolute inset-0 flex items-center">
<span class="w-full border-t"></span> <span class="w-full border-t"></span>
</div> </div>
<div class="relative flex justify-center text-xs uppercase"> <div class="relative flex justify-center text-xs uppercase">
<span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span> <span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span>
</div> </div>
</div> </div>
{#each data.oauthProviders as provider (provider.id)} {#each data.oauthProviders as provider}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
class="w-full" class="w-full"
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })} onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
> >
{t.auth.signUp} with {provider.name} {t.auth.signUp} with {provider.name}
</Button> </Button>
{/each} {/each}
{/if} {/if}
<div class="text-center text-sm text-muted-foreground"> <div class="text-center text-sm text-muted-foreground">
{t.auth.alreadyHaveAccount} {t.auth.alreadyHaveAccount}
<a href="/signin" class="text-primary hover:underline">{t.auth.signIn}</a> <a href="/signin" class="text-primary hover:underline">{t.auth.signIn}</a>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+142 -134
View File
@@ -1,178 +1,186 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, items, reservations, savedWishlists, type NewSavedWishlist } from '$lib/db/schema'; import { wishlists, items, reservations, savedWishlists } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.publicToken, params.token), where: eq(wishlists.publicToken, params.token),
with: { with: {
items: { items: {
orderBy: (items, { asc }) => [asc(items.order)], orderBy: (items, { asc }) => [asc(items.order)],
with: { with: {
reservations: true reservations: true
} }
} }
} }
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const session = await locals.auth(); const session = await locals.auth();
let isSaved = false; let isSaved = false;
let isClaimed = false; let isClaimed = false;
let savedWishlistId: string | null = null; let savedWishlistId: string | null = null;
if (session?.user?.id) { if (session?.user?.id) {
const saved = await db.query.savedWishlists.findFirst({ const saved = await db.query.savedWishlists.findFirst({
where: and( where: and(
eq(savedWishlists.userId, session.user.id), eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id) eq(savedWishlists.wishlistId, wishlist.id)
) )
}); });
isSaved = !!saved; isSaved = !!saved;
isClaimed = !!saved?.ownerToken; isClaimed = !!saved?.ownerToken;
savedWishlistId = saved?.id || null; savedWishlistId = saved?.id || null;
} }
return { return {
wishlist, wishlist,
isSaved, isSaved,
isClaimed, isClaimed,
savedWishlistId, savedWishlistId,
isAuthenticated: !!session?.user, isAuthenticated: !!session?.user,
currentUserId: session?.user?.id || null currentUserId: session?.user?.id || null
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
reserve: async ({ request, locals }) => { reserve: async ({ request, locals }) => {
const formData = await request.formData(); const formData = await request.formData();
const itemId = formData.get('itemId') as string; const itemId = formData.get('itemId') as string;
const reserverName = formData.get('reserverName') as string; const reserverName = formData.get('reserverName') as string;
if (!itemId) { if (!itemId) {
return { success: false, error: 'Item ID is required' }; return { success: false, error: 'Item ID is required' };
} }
const session = await locals.auth(); const session = await locals.auth();
const existingReservation = await db.query.reservations.findFirst({ const existingReservation = await db.query.reservations.findFirst({
where: eq(reservations.itemId, itemId) where: eq(reservations.itemId, itemId)
}); });
if (existingReservation) { if (existingReservation) {
return { success: false, error: 'This item is already reserved' }; return { success: false, error: 'This item is already reserved' };
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.insert(reservations).values({ await tx.insert(reservations).values({
itemId, itemId,
userId: session?.user?.id || null, userId: session?.user?.id || null,
reserverName: reserverName?.trim() || null reserverName: reserverName?.trim() || null
}); });
await tx.update(items).set({ isReserved: true }).where(eq(items.id, itemId)); await tx
}); .update(items)
.set({ isReserved: true })
.where(eq(items.id, itemId));
});
return { success: true }; return { success: true };
}, },
unreserve: async ({ request, locals }) => { unreserve: async ({ request, locals }) => {
const formData = await request.formData(); const formData = await request.formData();
const itemId = formData.get('itemId') as string; const itemId = formData.get('itemId') as string;
if (!itemId) { if (!itemId) {
return { success: false, error: 'Item ID is required' }; return { success: false, error: 'Item ID is required' };
} }
const session = await locals.auth(); const session = await locals.auth();
const reservation = await db.query.reservations.findFirst({ const reservation = await db.query.reservations.findFirst({
where: eq(reservations.itemId, itemId) where: eq(reservations.itemId, itemId)
}); });
if (!reservation) { if (!reservation) {
return { success: false, error: 'Reservation not found' }; return { success: false, error: 'Reservation not found' };
} }
if (reservation.userId) { if (reservation.userId) {
if (!session?.user?.id || session.user.id !== reservation.userId) { if (!session?.user?.id || session.user.id !== reservation.userId) {
return { return {
success: false, success: false,
error: 'You can only cancel your own reservations' error: 'You can only cancel your own reservations'
}; };
} }
} }
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.delete(reservations).where(eq(reservations.itemId, itemId)); await tx.delete(reservations).where(eq(reservations.itemId, itemId));
await tx.update(items).set({ isReserved: false }).where(eq(items.id, itemId)); await tx
}); .update(items)
.set({ isReserved: false })
.where(eq(items.id, itemId));
});
return { success: true }; return { success: true };
}, },
saveWishlist: async ({ request, locals }) => { saveWishlist: async ({ request, locals, params }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return { success: false, error: 'You must be logged in to save wishlists' }; return { success: false, error: 'You must be logged in to save wishlists' };
} }
const formData = await request.formData(); const formData = await request.formData();
const wishlistId = formData.get('wishlistId') as string; const wishlistId = formData.get('wishlistId') as string;
if (!wishlistId) { if (!wishlistId) {
return { success: false, error: 'Wishlist ID is required' }; return { success: false, error: 'Wishlist ID is required' };
} }
const existing = await db.query.savedWishlists.findFirst({ const existing = await db.query.savedWishlists.findFirst({
where: and( where: and(
eq(savedWishlists.userId, session.user.id), eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlistId) eq(savedWishlists.wishlistId, wishlistId)
) )
}); });
if (existing) { if (existing) {
return { success: false, error: 'Wishlist already saved' }; return { success: false, error: 'Wishlist already saved' };
} }
// Save without ownerToken - user is accessing via public link, so no edit access // Save without ownerToken - user is accessing via public link, so no edit access
const newSavedWishlist: NewSavedWishlist = { await db.insert(savedWishlists).values({
userId: session.user.id, userId: session.user.id,
wishlistId, wishlistId,
ownerToken: null // Explicitly set to null - no edit access from reservation view ownerToken: null // Explicitly set to null - no edit access from reservation view
}; });
await db.insert(savedWishlists).values(newSavedWishlist);
return { success: true }; return { success: true };
}, },
unsaveWishlist: async ({ request, locals }) => { unsaveWishlist: async ({ request, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
return { success: false, error: 'You must be logged in' }; return { success: false, error: 'You must be logged in' };
} }
const formData = await request.formData(); const formData = await request.formData();
const savedWishlistId = formData.get('savedWishlistId') as string; const savedWishlistId = formData.get('savedWishlistId') as string;
if (!savedWishlistId) { if (!savedWishlistId) {
return { success: false, error: 'Saved wishlist ID is required' }; return { success: false, error: 'Saved wishlist ID is required' };
} }
await db await db
.delete(savedWishlists) .delete(savedWishlists)
.where( .where(
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id)) and(
); eq(savedWishlists.id, savedWishlistId),
eq(savedWishlists.userId, session.user.id)
)
);
return { success: true }; return { success: true };
} }
}; };
+125 -115
View File
@@ -1,127 +1,137 @@
<script lang="ts"> <script lang="ts">
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardTitle CardHeader,
} from '$lib/components/ui/card'; CardTitle,
import { Button } from '$lib/components/ui/button'; } from "$lib/components/ui/card";
import type { PageData } from './$types'; import { Button } from "$lib/components/ui/button";
import WishlistItem from '$lib/components/wishlist/WishlistItem.svelte'; import type { PageData } from "./$types";
import ReservationButton from '$lib/components/wishlist/ReservationButton.svelte'; import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
import PageContainer from '$lib/components/layout/PageContainer.svelte'; import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte";
import Navigation from '$lib/components/layout/Navigation.svelte'; import PageContainer from "$lib/components/layout/PageContainer.svelte";
import EmptyState from '$lib/components/layout/EmptyState.svelte'; import Navigation from "$lib/components/layout/Navigation.svelte";
import { enhance } from '$app/forms'; import EmptyState from "$lib/components/layout/EmptyState.svelte";
import { getCardStyle } from '$lib/utils/colors'; import { enhance } from "$app/forms";
import { languageStore } from '$lib/stores/language.svelte'; import { getCardStyle } from "$lib/utils/colors";
import SearchBar from '$lib/components/ui/SearchBar.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte'; import SearchBar from "$lib/components/ui/SearchBar.svelte";
import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let searchQuery = $state(''); let searchQuery = $state('');
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
const headerCardStyle = $derived(getCardStyle(data.wishlist.color)); const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
const filteredItems = $derived( const filteredItems = $derived(
data.wishlist.items?.filter( data.wishlist.items?.filter(item =>
(item) => item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) || item.description?.toLowerCase().includes(searchQuery.toLowerCase())
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) ) || []
) || [] );
);
</script> </script>
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}> <PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
<Navigation <Navigation
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
showDashboardLink={true} showDashboardLink={true}
color={data.wishlist.color} color={data.wishlist.color}
/> />
<Card style={headerCardStyle}> <Card style={headerCardStyle}>
<CardContent class="pt-6"> <CardContent class="pt-6">
<div class="flex flex-wrap items-start justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex-1"> <div class="flex-1">
<CardTitle class="text-3xl">{data.wishlist.title}</CardTitle> <CardTitle class="text-3xl">{data.wishlist.title}</CardTitle>
{#if data.wishlist.description} {#if data.wishlist.description}
<CardDescription class="text-base">{data.wishlist.description}</CardDescription> <CardDescription class="text-base"
{/if} >{data.wishlist.description}</CardDescription
</div> >
{#if data.isAuthenticated} {/if}
{#if data.isClaimed} </div>
<Button variant="outline" size="sm" disabled> {#if data.isAuthenticated}
{t.wishlist.youClaimedThis} {#if data.isClaimed}
</Button> <Button
{:else if data.isSaved} variant="outline"
<form method="POST" action="?/unsaveWishlist" use:enhance> size="sm"
<input type="hidden" name="savedWishlistId" value={data.savedWishlistId} /> disabled
<Button type="submit" variant="outline" size="sm"> >
{t.wishlist.unsaveWishlist} {t.wishlist.youClaimedThis}
</Button> </Button>
</form> {:else if data.isSaved}
{:else} <form method="POST" action="?/unsaveWishlist" use:enhance>
<form <input
method="POST" type="hidden"
action="?/saveWishlist" name="savedWishlistId"
use:enhance={() => { value={data.savedWishlistId}
return async ({ update }) => { />
await update({ reset: false }); <Button type="submit" variant="outline" size="sm">
}; {t.wishlist.unsaveWishlist}
}} </Button>
> </form>
<input type="hidden" name="wishlistId" value={data.wishlist.id} /> {:else}
<Button type="submit" variant="outline" size="sm"> <form method="POST" action="?/saveWishlist" use:enhance={() => {
{t.wishlist.saveWishlist} return async ({ update }) => {
</Button> await update({ reset: false });
</form> };
{/if} }}>
{:else} <input type="hidden" name="wishlistId" value={data.wishlist.id} />
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')}> <Button type="submit" variant="outline" size="sm">
{t.wishlist.signInToSave} {t.wishlist.saveWishlist}
</Button> </Button>
{/if} </form>
</div> {/if}
</CardContent> {:else}
</Card> <Button
variant="outline"
size="sm"
onclick={() => (window.location.href = "/signin")}
>
{t.wishlist.signInToSave}
</Button>
{/if}
</div>
</CardContent>
</Card>
{#if data.wishlist.items && data.wishlist.items.length > 0} {#if data.wishlist.items && data.wishlist.items.length > 0}
<SearchBar bind:value={searchQuery} /> <SearchBar bind:value={searchQuery} />
{/if} {/if}
<div class="space-y-4"> <div class="space-y-4">
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
{#each filteredItems as item (item.id)} {#each filteredItems as item}
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}> <WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
<ReservationButton <ReservationButton
itemId={item.id} itemId={item.id}
isReserved={item.isReserved} isReserved={item.isReserved}
reserverName={item.reservations?.[0]?.reserverName} reserverName={item.reservations?.[0]?.reserverName}
reservationUserId={item.reservations?.[0]?.userId} reservationUserId={item.reservations?.[0]?.userId}
currentUserId={data.currentUserId} currentUserId={data.currentUserId}
/> />
</WishlistItem> </WishlistItem>
{/each} {/each}
{:else if data.wishlist.items && data.wishlist.items.length > 0} {:else if data.wishlist.items && data.wishlist.items.length > 0}
<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} />
<CardContent class="p-12 relative z-10"> <CardContent class="p-12 relative z-10">
<EmptyState message="No wishes match your search." /> <EmptyState
</CardContent> message="No wishes match your search."
</Card> />
{:else} </CardContent>
<Card style={headerCardStyle} class="relative overflow-hidden"> </Card>
<ThemeCard {:else}
themeName={data.wishlist.theme} <Card style={headerCardStyle} class="relative overflow-hidden">
color={data.wishlist.color} <ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} showPattern={false} />
showPattern={false} <CardContent class="p-12 relative z-10">
/> <EmptyState
<CardContent class="p-12 relative z-10"> message={t.wishlist.emptyWishes}
<EmptyState message={t.wishlist.emptyWishes} /> />
</CardContent> </CardContent>
</Card> </Card>
{/if} {/if}
</div> </div>
</PageContainer> </PageContainer>
+255 -210
View File
@@ -1,269 +1,314 @@
import { error, fail } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, items, savedWishlists, type NewItem, type NewSavedWishlist } from '$lib/db/schema'; import { wishlists, items, savedWishlists } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { itemSchema, updateItemSchema, wishlistSchema, reorderSchema } from '$lib/server/validation';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token), where: eq(wishlists.ownerToken, params.token),
with: { with: {
items: { items: {
orderBy: (items, { asc }) => [asc(items.order)] orderBy: (items, { asc }) => [asc(items.order)]
} }
} }
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const session = await locals.auth(); const session = await locals.auth();
let hasClaimed = false; let hasClaimed = false;
let isOwner = false; let isOwner = false;
if (session?.user?.id) { if (session?.user?.id) {
// Check if user is the owner // Check if user is the owner
isOwner = wishlist.userId === session.user.id; isOwner = wishlist.userId === session.user.id;
// Check if user has claimed this wishlist // Check if user has claimed this wishlist
const savedWishlist = await db.query.savedWishlists.findFirst({ const savedWishlist = await db.query.savedWishlists.findFirst({
where: and( where: and(
eq(savedWishlists.userId, session.user.id), eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id) eq(savedWishlists.wishlistId, wishlist.id)
) )
}); });
hasClaimed = !!savedWishlist; hasClaimed = !!savedWishlist;
} }
return { return {
wishlist, wishlist,
publicUrl: `/wishlist/${wishlist.publicToken}`, publicUrl: `/wishlist/${wishlist.publicToken}`,
isAuthenticated: !!session?.user, isAuthenticated: !!session?.user,
hasClaimed, hasClaimed,
isOwner isOwner
}; };
}; };
export const actions: Actions = { export const actions: Actions = {
addItem: async ({ params, request }) => { addItem: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const rawData = Object.fromEntries(formData); const title = formData.get('title') as string;
const description = formData.get('description') as string;
const result = itemSchema.safeParse(rawData); const link = formData.get('link') as string;
if (!result.success) { const imageUrl = formData.get('imageUrl') as string;
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') }); const price = formData.get('price') as string;
} const currency = formData.get('currency') as string;
const color = formData.get('color') as string;
const wishlist = await db.query.wishlists.findFirst({ if (!title?.trim()) {
where: eq(wishlists.ownerToken, params.token), return { success: false, error: 'Title is required' };
with: { items: true } }
});
if (!wishlist) { const wishlist = await db.query.wishlists.findFirst({
throw error(404, 'Wishlist not found'); where: eq(wishlists.ownerToken, params.token),
} with: {
items: true
}
});
const maxOrder = wishlist.items.reduce((max, item) => { if (!wishlist) {
const order = Number(item.order) || 0; throw error(404, 'Wishlist not found');
return order > max ? order : max; }
}, 0);
const newItem: NewItem = { // Get the max order value and add 1
wishlistId: wishlist.id, const maxOrder = wishlist.items.reduce((max, item) => {
...result.data, const order = Number(item.order) || 0;
price: result.data.price?.toString() || null, return order > max ? order : max;
order: String(maxOrder + 1) }, 0);
};
await db.insert(items).values(newItem);
return { success: true }; await db.insert(items).values({
}, wishlistId: wishlist.id,
title: title.trim(),
description: description?.trim() || null,
link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK',
color: color?.trim() || null,
order: String(maxOrder + 1)
});
updateItem: async ({ params, request }) => { return { success: true };
const formData = await request.formData(); },
const rawData = Object.fromEntries(formData);
const result = updateItemSchema.safeParse(rawData);
if (!result.success) {
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
}
const wishlist = await db.query.wishlists.findFirst({ updateItem: async ({ params, request }) => {
where: eq(wishlists.ownerToken, params.token) const formData = await request.formData();
}); const itemId = formData.get('itemId') as string;
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const link = formData.get('link') as string;
const imageUrl = formData.get('imageUrl') as string;
const price = formData.get('price') as string;
const currency = formData.get('currency') as string;
const color = formData.get('color') as string;
if (!wishlist) { if (!itemId) {
throw error(404, 'Wishlist not found'); return { success: false, error: 'Item ID is required' };
} }
const { itemId, ...data } = result.data; if (!title?.trim()) {
await db return { success: false, error: 'Title is required' };
.update(items) }
.set({
...data,
price: data.price?.toString() || null,
updatedAt: new Date()
})
.where(eq(items.id, itemId));
return { success: true }; const wishlist = await db.query.wishlists.findFirst({
}, where: eq(wishlists.ownerToken, params.token)
});
deleteItem: async ({ params, request }) => { if (!wishlist) {
const formData = await request.formData(); throw error(404, 'Wishlist not found');
const itemId = formData.get('itemId') as string; }
if (!itemId) { await db.update(items)
return { success: false, error: 'Item ID is required' }; .set({
} title: title.trim(),
description: description?.trim() || null,
link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK',
color: color?.trim() || null,
updatedAt: new Date()
})
.where(eq(items.id, itemId));
const wishlist = await db.query.wishlists.findFirst({ return { success: true };
where: eq(wishlists.ownerToken, params.token) },
});
if (!wishlist) { deleteItem: async ({ params, request }) => {
throw error(404, 'Wishlist not found'); const formData = await request.formData();
} const itemId = formData.get('itemId') as string;
await db.delete(items).where(eq(items.id, itemId)); if (!itemId) {
return { success: false, error: 'Item ID is required' };
}
return { success: true }; const wishlist = await db.query.wishlists.findFirst({
}, where: eq(wishlists.ownerToken, params.token)
});
reorderItems: async ({ params, request }) => { if (!wishlist) {
const formData = await request.formData(); throw error(404, 'Wishlist not found');
const itemsJson = formData.get('items') as string; }
const wishlist = await db.query.wishlists.findFirst({ await db.delete(items).where(eq(items.id, itemId));
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) { return { success: true };
throw error(404, 'Wishlist not found'); },
}
const result = reorderSchema.safeParse(JSON.parse(itemsJson || '[]')); reorderItems: async ({ params, request }) => {
if (!result.success) { const formData = await request.formData();
return fail(400, { success: false, error: 'Invalid reorder data' }); const itemsJson = formData.get('items') as string;
}
for (const update of result.data) { if (!itemsJson) {
await db return { success: false, error: 'Items data is required' };
.update(items) }
.set({ order: String(update.order), updatedAt: new Date() })
.where(eq(items.id, update.id));
}
return { success: true }; const wishlist = await db.query.wishlists.findFirst({
}, where: eq(wishlists.ownerToken, params.token)
});
deleteWishlist: async ({ params }) => { if (!wishlist) {
const wishlist = await db.query.wishlists.findFirst({ throw error(404, 'Wishlist not found');
where: eq(wishlists.ownerToken, params.token) }
});
if (!wishlist) { const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
throw error(404, 'Wishlist not found');
}
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id)); for (const update of updates) {
await db.update(items)
.set({ order: String(update.order), updatedAt: new Date() })
.where(eq(items.id, update.id));
}
return { success: true, redirect: '/dashboard' }; return { success: true };
}, },
updateWishlist: async ({ params, request }) => { deleteWishlist: async ({ params }) => {
const formData = await request.formData(); const wishlist = await db.query.wishlists.findFirst({
const rawData = Object.fromEntries(formData); where: eq(wishlists.ownerToken, params.token)
});
// Only validate fields that are present
const partialSchema = wishlistSchema.partial();
const result = partialSchema.safeParse(rawData);
if (!result.success) {
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
}
const wishlist = await db.query.wishlists.findFirst({ if (!wishlist) {
where: eq(wishlists.ownerToken, params.token) throw error(404, 'Wishlist not found');
}); }
if (!wishlist) { await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
throw error(404, 'Wishlist not found');
}
await db return { success: true, redirect: '/dashboard' };
.update(wishlists) },
.set({
...result.data,
updatedAt: new Date()
})
.where(eq(wishlists.id, wishlist.id));
return { success: true }; updateWishlist: async ({ params, request }) => {
}, const formData = await request.formData();
const color = formData.get('color');
const title = formData.get('title');
const description = formData.get('description');
const endDate = formData.get('endDate');
const theme = formData.get('theme');
claimWishlist: async ({ params, locals }) => { const wishlist = await db.query.wishlists.findFirst({
const session = await locals.auth(); where: eq(wishlists.ownerToken, params.token)
});
if (!session?.user?.id) { if (!wishlist) {
throw error(401, 'You must be signed in to claim a wishlist'); throw error(404, 'Wishlist not found');
} }
const wishlist = await db.query.wishlists.findFirst({ const updates: any = {
where: eq(wishlists.ownerToken, params.token) updatedAt: new Date()
}); };
if (!wishlist) { if (color !== null) {
throw error(404, 'Wishlist not found'); updates.color = color?.toString().trim() || null;
} }
// Check if already claimed if (title !== null) {
const existing = await db.query.savedWishlists.findFirst({ const titleStr = title?.toString().trim();
where: and( if (!titleStr) {
eq(savedWishlists.userId, session.user.id), return { success: false, error: 'Title is required' };
eq(savedWishlists.wishlistId, wishlist.id) }
) updates.title = titleStr;
}); }
if (existing) { if (description !== null) {
return { success: true, message: 'Already claimed' }; updates.description = description?.toString().trim() || null;
} }
// Store the ownerToken - user is accessing via edit link, so they get edit access if (endDate !== null) {
const newSavedWishlist: NewSavedWishlist = { const endDateStr = endDate?.toString().trim();
userId: session.user.id, updates.endDate = endDateStr ? new Date(endDateStr) : null;
wishlistId: wishlist.id, }
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
isFavorite: false
};
await db.insert(savedWishlists).values(newSavedWishlist);
return { success: true, message: 'Wishlist claimed successfully' }; if (theme !== null) {
}, updates.theme = theme?.toString().trim() || 'none';
}
unclaimWishlist: async ({ params, locals }) => { await db.update(wishlists)
const session = await locals.auth(); .set(updates)
.where(eq(wishlists.id, wishlist.id));
if (!session?.user?.id) { return { success: true };
throw error(401, 'You must be signed in'); },
}
const wishlist = await db.query.wishlists.findFirst({ claimWishlist: async ({ params, locals }) => {
where: eq(wishlists.ownerToken, params.token) const session = await locals.auth();
});
if (!wishlist) { if (!session?.user?.id) {
throw error(404, 'Wishlist not found'); throw error(401, 'You must be signed in to claim a wishlist');
} }
await db const wishlist = await db.query.wishlists.findFirst({
.delete(savedWishlists) where: eq(wishlists.ownerToken, params.token)
.where( });
and(eq(savedWishlists.userId, session.user.id), eq(savedWishlists.wishlistId, wishlist.id))
);
return { success: true, message: 'Wishlist unclaimed' }; if (!wishlist) {
} throw error(404, 'Wishlist not found');
}
// Check if already claimed
const existing = await db.query.savedWishlists.findFirst({
where: and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
});
if (existing) {
return { success: true, message: 'Already claimed' };
}
// Store the ownerToken - user is accessing via edit link, so they get edit access
await db.insert(savedWishlists).values({
userId: session.user.id,
wishlistId: wishlist.id,
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
isFavorite: false
});
return { success: true, message: 'Wishlist claimed successfully' };
},
unclaimWishlist: async ({ params, locals }) => {
const session = await locals.auth();
if (!session?.user?.id) {
throw error(401, 'You must be signed in');
}
const wishlist = await db.query.wishlists.findFirst({
where: eq(wishlists.ownerToken, params.token)
});
if (!wishlist) {
throw error(404, 'Wishlist not found');
}
await db.delete(savedWishlists).where(
and(
eq(savedWishlists.userId, session.user.id),
eq(savedWishlists.wishlistId, wishlist.id)
)
);
return { success: true, message: 'Wishlist unclaimed' };
}
}; };

Some files were not shown because too many files have changed in this diff Show More