style: format entire codebase with prettier

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

View File

@@ -5,4 +5,4 @@
"defaultEnv": "Production", "defaultEnv": "Production",
"envId": "496d0105-f2b4-424d-a1a1-a60602fc2252", "envId": "496d0105-f2b4-424d-a1a1-a60602fc2252",
"monorepoSupport": false "monorepoSupport": false
} }

View File

@@ -1,9 +1,9 @@
{ {
"useTabs": false, "useTabs": false,
"tabWidth": 2, "tabWidth": 2,
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 100,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }

View File

@@ -35,11 +35,13 @@ 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`)
@@ -61,6 +63,7 @@ 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
``` ```

View File

@@ -16,6 +16,7 @@ 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
@@ -26,6 +27,7 @@ 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 \
@@ -57,15 +59,18 @@ 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
``` ```

209
bun.lock
View File

@@ -1,6 +1,5 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "wishlist-app", "name": "wishlist-app",
@@ -14,13 +13,13 @@
"bits-ui": "^2.14.4", "bits-ui": "^2.14.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"lucide-svelte": "^0.554.0",
"postgres": "^3.4.7", "postgres": "^3.4.7",
"svelte-dnd-action": "^0.9.67", "svelte-dnd-action": "^0.9.67",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0",
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.4.0",
@@ -29,13 +28,19 @@
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7",
"eslint": "^9.25.0",
"eslint-plugin-svelte": "^3.5.1",
"globals": "^16.0.0",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.43.8", "svelte": "^5.43.8",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.31.0",
"vite": "^7.2.2", "vite": "^7.2.2",
}, },
}, },
@@ -105,12 +110,38 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="],
"@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], "@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/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=="], "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@internationalized/date": ["@internationalized/date@3.10.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw=="], "@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/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=="],
@@ -237,26 +268,58 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@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=="], "@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/utils": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.57.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0" } }, "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.57.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.57.0", "", {}, "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.57.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.57.0", "", { "dependencies": { "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg=="],
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="], "@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=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="], "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=="], "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=="], "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=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "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=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@@ -267,6 +330,8 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "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=="], "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@@ -281,12 +346,18 @@
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="], "commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], "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=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], "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=="], "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=="],
@@ -317,18 +388,52 @@
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="],
"eslint-plugin-svelte": ["eslint-plugin-svelte@3.15.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.6.1", "@jridgewell/sourcemap-codec": "^1.5.0", "esutils": "^2.0.3", "globals": "^16.0.0", "known-css-properties": "^0.37.0", "postcss": "^8.4.49", "postcss-load-config": "^3.1.4", "postcss-safe-parser": "^7.0.0", "semver": "^7.6.3", "svelte-eslint-parser": "^1.4.0" }, "peerDependencies": { "eslint": "^8.57.1 || ^9.0.0 || ^10.0.0", "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-k4Nsjs3bHujeEnnckoTM4mFYR1e8Mb9l2rTwNdmYiamA+Tjzn8X+2F+fuSP2w4VbXYhn2bmySyACQYdmUDW2Cg=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrap": ["esrap@2.1.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg=="], "esrap": ["esrap@2.1.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="], "find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.1", "", {}, "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ=="],
"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=="], "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=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -341,6 +446,10 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "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=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -353,12 +462,22 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], "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-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-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="], "is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
@@ -375,16 +494,30 @@
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], "jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"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=="], "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=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "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=="], "jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="], "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=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"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": ["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-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -409,9 +542,13 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], "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=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
@@ -421,6 +558,8 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -431,6 +570,8 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="], "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=="], "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=="],
@@ -441,8 +582,18 @@
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="], "open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"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=="], "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-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
@@ -453,6 +604,14 @@
"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=="], "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=="],
"postcss-load-config": ["postcss-load-config@3.1.4", "", { "dependencies": { "lilconfig": "^2.0.5", "yaml": "^1.10.2" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg=="],
"postcss-safe-parser": ["postcss-safe-parser@7.0.1", "", { "peerDependencies": { "postcss": "^8.4.31" } }, "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A=="],
"postcss-scss": ["postcss-scss@4.0.9", "", { "peerDependencies": { "postcss": "^8.4.29" } }, "sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="], "postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
@@ -461,12 +620,22 @@
"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=="], "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=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.5.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg=="],
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "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": ["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-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "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=="], "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=="],
@@ -495,6 +664,8 @@
"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=="], "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=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], "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-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -507,6 +678,8 @@
"svelte-dnd-action": ["svelte-dnd-action@0.9.67", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-yEJQZ9SFy3O4mnOdtjwWyotRsWRktNf4W8k67zgiLiMtMNQnwCyJHBjkGMgZMDh8EGZ4gr88l+GebBWoHDwo+g=="], "svelte-dnd-action": ["svelte-dnd-action@0.9.67", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-yEJQZ9SFy3O4mnOdtjwWyotRsWRktNf4W8k67zgiLiMtMNQnwCyJHBjkGMgZMDh8EGZ4gr88l+GebBWoHDwo+g=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.6.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0", "semver": "^7.7.2" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-qoB1ehychT6OxEtQAqc/guSqLS20SlA53Uijl7x375s8nlUT0lb9ol/gzraEEatQwsyPTJo87s2CmKL9Xab+Uw=="],
"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=="], "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=="], "tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="],
@@ -527,24 +700,38 @@
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "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=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.57.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/parser": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/utils": "8.57.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"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=="], "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=="], "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=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], "yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], "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/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=="],
@@ -553,6 +740,10 @@
"@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=="], "@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=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="], "@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/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
@@ -567,8 +758,16 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"postcss-load-config/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="],
"@auth/drizzle-adapter/@auth/core/jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="], "@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/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
@@ -628,5 +827,9 @@
"@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-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=="], "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
} }
} }

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"
} }

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

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:-}

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/db/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;

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]
}), })
})); }));

View File

@@ -1,141 +1,186 @@
import { pgTable, foreignKey, text, numeric, boolean, timestamp, unique, primaryKey } from "drizzle-orm/pg-core" import {
import { sql } from "drizzle-orm" pgTable,
foreignKey,
text,
numeric,
boolean,
timestamp,
unique,
primaryKey
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
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 items = pgTable("items", { export const savedWishlists = pgTable(
id: text().primaryKey().notNull(), 'saved_wishlists',
wishlistId: text("wishlist_id").notNull(), {
title: text().notNull(), id: text().primaryKey().notNull(),
description: text(), userId: text('user_id').notNull(),
link: text(), wishlistId: text('wishlist_id').notNull(),
imageUrl: text("image_url"), isFavorite: boolean('is_favorite').default(false).notNull(),
price: numeric({ precision: 10, scale: 2 }), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
currency: text().default('DKK'), ownerToken: text('owner_token')
color: text(), },
order: numeric().default('0').notNull(), (table) => [
isReserved: boolean("is_reserved").default(false).notNull(), foreignKey({
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), columns: [table.userId],
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(), foreignColumns: [user.id],
}, (table) => [ name: 'saved_wishlists_user_id_user_id_fk'
foreignKey({ }).onDelete('cascade'),
columns: [table.wishlistId], foreignKey({
foreignColumns: [wishlists.id], columns: [table.wishlistId],
name: "items_wishlist_id_wishlists_id_fk" foreignColumns: [wishlists.id],
}).onDelete("cascade"), name: 'saved_wishlists_wishlist_id_wishlists_id_fk'
]); }).onDelete('cascade')
]
);
export const wishlists = pgTable("wishlists", { export const user = pgTable(
id: text().primaryKey().notNull(), 'user',
userId: text("user_id"), {
title: text().notNull(), id: text().primaryKey().notNull(),
description: text(), name: text(),
ownerToken: text("owner_token").notNull(), email: text(),
publicToken: text("public_token").notNull(), emailVerified: timestamp({ mode: 'string' }),
isFavorite: boolean("is_favorite").default(false).notNull(), image: text(),
color: text(), password: text(),
endDate: timestamp("end_date", { mode: 'string' }), username: text(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), dashboardTheme: text('dashboard_theme').default('none'),
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(), dashboardColor: text('dashboard_color'),
theme: text().default('none'), lastLogin: timestamp('last_login', { mode: 'string' }),
}, (table) => [ createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
foreignKey({ updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull()
columns: [table.userId], },
foreignColumns: [user.id], (table) => [
name: "wishlists_user_id_user_id_fk" unique('user_email_unique').on(table.email),
}).onDelete("set null"), unique('user_username_unique').on(table.username)
unique("wishlists_owner_token_unique").on(table.ownerToken), ]
unique("wishlists_public_token_unique").on(table.publicToken), );
]);
export const savedWishlists = pgTable("saved_wishlists", { export const reservations = pgTable(
id: text().primaryKey().notNull(), 'reservations',
userId: text("user_id").notNull(), {
wishlistId: text("wishlist_id").notNull(), id: text().primaryKey().notNull(),
isFavorite: boolean("is_favorite").default(false).notNull(), itemId: text('item_id').notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), reserverName: text('reserver_name'),
ownerToken: text("owner_token"), createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull()
}, (table) => [ },
foreignKey({ (table) => [
columns: [table.userId], foreignKey({
foreignColumns: [user.id], columns: [table.itemId],
name: "saved_wishlists_user_id_user_id_fk" foreignColumns: [items.id],
}).onDelete("cascade"), name: 'reservations_item_id_items_id_fk'
foreignKey({ }).onDelete('cascade')
columns: [table.wishlistId], ]
foreignColumns: [wishlists.id], );
name: "saved_wishlists_wishlist_id_wishlists_id_fk"
}).onDelete("cascade"),
]);
export const user = pgTable("user", { export const session = pgTable(
id: text().primaryKey().notNull(), 'session',
name: text(), {
email: text(), sessionToken: text().primaryKey().notNull(),
emailVerified: timestamp({ mode: 'string' }), userId: text().notNull(),
image: text(), expires: timestamp({ mode: 'string' }).notNull()
password: text(), },
username: text(), (table) => [
dashboardTheme: text("dashboard_theme").default('none'), foreignKey({
dashboardColor: text("dashboard_color"), columns: [table.userId],
lastLogin: timestamp("last_login", { mode: 'string' }), foreignColumns: [user.id],
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), name: 'session_userId_user_id_fk'
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(), }).onDelete('cascade')
}, (table) => [ ]
unique("user_email_unique").on(table.email), );
unique("user_username_unique").on(table.username),
]);
export const reservations = pgTable("reservations", { export const verificationToken = pgTable(
id: text().primaryKey().notNull(), 'verificationToken',
itemId: text("item_id").notNull(), {
reserverName: text("reserver_name"), identifier: text().notNull(),
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), token: text().notNull(),
}, (table) => [ expires: timestamp({ mode: 'string' }).notNull()
foreignKey({ },
columns: [table.itemId], (table) => [
foreignColumns: [items.id], primaryKey({
name: "reservations_item_id_items_id_fk" columns: [table.identifier, table.token],
}).onDelete("cascade"), name: 'verificationToken_identifier_token_pk'
]); })
]
);
export const session = pgTable("session", { export const account = pgTable(
sessionToken: text().primaryKey().notNull(), 'account',
userId: text().notNull(), {
expires: timestamp({ mode: 'string' }).notNull(), userId: text().notNull(),
}, (table) => [ type: text().notNull(),
foreignKey({ provider: text().notNull(),
columns: [table.userId], providerAccountId: text().notNull(),
foreignColumns: [user.id], refreshToken: text('refresh_token'),
name: "session_userId_user_id_fk" accessToken: text('access_token'),
}).onDelete("cascade"), expiresAt: numeric('expires_at'),
]); tokenType: text('token_type'),
scope: text(),
export const verificationToken = pgTable("verificationToken", { idToken: text('id_token'),
identifier: text().notNull(), sessionState: text('session_state')
token: text().notNull(), },
expires: timestamp({ mode: 'string' }).notNull(), (table) => [
}, (table) => [ foreignKey({
primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}), columns: [table.userId],
]); foreignColumns: [user.id],
name: 'account_userId_user_id_fk'
export const account = pgTable("account", { }).onDelete('cascade'),
userId: text().notNull(), primaryKey({
type: text().notNull(), columns: [table.provider, table.providerAccountId],
provider: text().notNull(), name: 'account_provider_providerAccountId_pk'
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"}),
]);

View File

@@ -5,26 +5,26 @@ import globals from 'globals';
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
export default [ export default [
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs['flat/recommended'], ...svelte.configs['flat/recommended'],
{ {
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node ...globals.node
} }
} }
}, },
{ {
files: ['**/*.svelte'], files: ['**/*.svelte'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
parser: ts.parser parser: ts.parser
} }
} }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'] ignores: ['build/', '.svelte-kit/', 'dist/']
} }
]; ];

View File

@@ -1,62 +1,61 @@
{ {
"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 .", "lint": "prettier --check . && eslint .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@lucide/svelte": "^0.544.0", "@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-auto": "^7.0.0", "@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.4.0", "@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.5", "@sveltejs/kit": "^2.48.5",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"drizzle-kit": "^0.31.7", "drizzle-kit": "^0.31.7",
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-plugin-svelte": "^3.5.1", "eslint-plugin-svelte": "^3.5.1",
"globals": "^16.0.0", "globals": "^16.0.0",
"patch-package": "^8.0.1", "patch-package": "^8.0.1",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.43.8", "svelte": "^5.43.8",
"svelte-check": "^4.3.4", "svelte-check": "^4.3.4",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.31.0", "typescript-eslint": "^8.31.0",
"vite": "^7.2.2" "vite": "^7.2.2"
}, },
"dependencies": { "dependencies": {
"@auth/core": "^0.34.3", "@auth/core": "^0.34.3",
"@auth/drizzle-adapter": "^1.11.1", "@auth/drizzle-adapter": "^1.11.1",
"@auth/sveltekit": "^1.11.1", "@auth/sveltekit": "^1.11.1",
"@internationalized/date": "^3.10.0", "@internationalized/date": "^3.10.0",
"@paralleldrive/cuid2": "^3.0.4", "@paralleldrive/cuid2": "^3.0.4",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bits-ui": "^2.14.4", "bits-ui": "^2.14.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"postgres": "^3.4.7",
"postgres": "^3.4.7", "svelte-dnd-action": "^0.9.67",
"svelte-dnd-action": "^0.9.67", "tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.4.0", "tailwind-variants": "^3.2.2"
"tailwind-variants": "^3.2.2" }
}
} }

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 {

16
src/app.d.ts vendored
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 {};

View File

@@ -1,21 +1,22 @@
<!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 = theme === 'dark' || const isDark =
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); theme === 'dark' ||
if (isDark) { (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.add('dark'); if (isDark) {
} document.documentElement.classList.add('dark');
})(); }
</script> })();
%sveltekit.head% </script>
</head> %sveltekit.head%
<body data-sveltekit-preload-data="hover"> </head>
<div style="display: contents">%sveltekit.body%</div> <body data-sveltekit-preload-data="hover">
</body> <div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>

View File

@@ -11,121 +11,116 @@ import { env } from '$env/dynamic/private';
import type { SvelteKitAuthConfig } from '@auth/sveltekit'; import type { SvelteKitAuthConfig } from '@auth/sveltekit';
function Authentik(config: { function Authentik(config: {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
issuer: string; issuer: string;
}): OAuthConfig<any> { }): 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( const isValidPassword = await bcrypt.compare(credentials.password as string, user.password);
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) await db.update(users).set({ lastLogin: new Date() }).where(eq(users.id, user.id));
.set({ lastLogin: new Date() }) }
.where(eq(users.id, user.id)); return true;
} },
return true; async jwt({ token, user }) {
}, if (user) {
async jwt({ token, user }) { token.id = user.id;
if (user) { }
token.id = user.id; return token;
} },
return token; async session({ session, token }) {
}, if (token && session.user) {
async session({ session, token }) { session.user.id = token.id as string;
if (token && session.user) { }
session.user.id = token.id as string; return session;
} }
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);

View File

@@ -1,158 +1,161 @@
<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 { getLocalWishlists, forgetLocalWishlist, toggleLocalFavorite, type LocalWishlist } from '$lib/utils/localWishlists'; import {
import { languageStore } from '$lib/stores/language.svelte'; getLocalWishlists,
import { Star } from '@lucide/svelte'; forgetLocalWishlist,
import { onMount } from 'svelte'; toggleLocalFavorite,
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<any[]>([]); 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 t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet."; return (
} t.dashboard.localWishlistsAuthDescription ||
return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently."; "Wishlists stored in your browser that haven't been claimed yet."
}); );
}
return (
t.dashboard.localWishlistsDescription ||
'Wishlists stored in your browser. Sign in to save them permanently.'
);
});
</script> </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, unlocked)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<Button <Button size="sm" variant="outline" onclick={() => handleToggleFavorite(wishlist.ownerToken)}>
size="sm" <Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
variant="outline" </Button>
onclick={() => handleToggleFavorite(wishlist.ownerToken)} <Button
> size="sm"
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} /> onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
</Button> >
<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}
> <Button size="sm" variant="destructive" onclick={() => handleForget(wishlist.ownerToken)}>
{t.dashboard.copyLink} {t.dashboard.forget || 'Forget'}
</Button> </Button>
{#if unlocked} {/if}
<Button </div>
size="sm" {/snippet}
variant="destructive"
onclick={() => handleForget(wishlist.ownerToken)}
>
{t.dashboard.forget || "Forget"}
</Button>
{/if}
</div>
{/snippet}
</WishlistSection> </WishlistSection>

View File

@@ -1,55 +1,61 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import {
import type { Snippet } from 'svelte'; Card,
import { getCardStyle } from '$lib/utils/colors'; CardContent,
import ThemeCard from '$lib/components/themes/ThemeCard.svelte'; CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import type { Snippet } from 'svelte';
import { getCardStyle } from '$lib/utils/colors';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
let { 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>

View File

@@ -1,97 +1,103 @@
<script lang="ts"> <script lang="ts">
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import {
import { Button } from '$lib/components/ui/button'; Card,
import EmptyState from '$lib/components/layout/EmptyState.svelte'; CardContent,
import type { Snippet } from 'svelte'; CardDescription,
import { flip } from 'svelte/animate'; CardHeader,
import { getCardStyle } from '$lib/utils/colors'; CardTitle
import ThemeCard from '$lib/components/themes/ThemeCard.svelte'; } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import EmptyState from '$lib/components/layout/EmptyState.svelte';
import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate';
import { getCardStyle } from '$lib/utils/colors';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
let { 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: any[]; 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<[any]>; 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>

View File

@@ -1,166 +1,170 @@
<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 { 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';
import SearchBar from '$lib/components/ui/SearchBar.svelte'; import SearchBar from '$lib/components/ui/SearchBar.svelte';
import UnlockButton from '$lib/components/ui/UnlockButton.svelte'; import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type WishlistItem = any; // You can make this more specific based on your types type WishlistItem = any; // You can make this more specific based on your types
let { 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, { year: 'numeric', month: 'short', day: 'numeric' }); return d.toLocaleDateString(languageStore.t.date.format.short, {
} year: 'numeric',
month: 'short',
day: 'numeric'
});
}
function getWishlistDescription(item: any): 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: any) => 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}

View File

@@ -1,92 +1,100 @@
<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'; 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 = $state(dashboardColor); let localColor = $state(dashboardColor);
$effect(() => { $effect(() => {
localColor = dashboardColor; localColor = dashboardColor;
}); });
async function handleColorChange() { async function handleColorChange() {
if (onColorUpdate) { if (onColorUpdate) {
onColorUpdate(localColor); onColorUpdate(localColor);
} }
if (isAuthenticated) { if (isAuthenticated) {
const formData = new FormData(); const formData = new FormData();
if (localColor) { if (localColor) {
formData.append('color', localColor); formData.append('color', localColor);
} }
await fetch('?/updateDashboardColor', { await fetch('?/updateDashboardColor', {
method: 'POST', method: 'POST',
body: formData 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">{t.dashboard.welcomeBack}, {userName || userEmail}</p> <p class="text-muted-foreground truncate">
{:else} {t.dashboard.welcomeBack}, {userName || userEmail}
<p class="text-muted-foreground">{t.dashboard.anonymousDashboard || "Your local wishlists"}</p> </p>
{/if} {:else}
</div> <p class="text-muted-foreground">
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0"> {t.dashboard.anonymousDashboard || 'Your local wishlists'}
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" /> </p>
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} /> {/if}
<LanguageToggle color={localColor} /> </div>
<ThemeToggle /> <div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
{#if isAuthenticated} <ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button> <ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
{:else} <LanguageToggle color={localColor} />
<Button variant="outline" onclick={() => (window.location.href = '/signin')}>{t.auth.signIn}</Button> <ThemeToggle />
{/if} {#if isAuthenticated}
</div> <Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}
>{t.auth.signOut}</Button
>
{:else}
<Button variant="outline" onclick={() => (window.location.href = '/signin')}
>{t.auth.signIn}</Button
>
{/if}
</div>
</div> </div>

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>

View File

@@ -1,36 +1,46 @@
<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,
showDashboardLink = false, showDashboardLink = false,
color = null color = null
}: { }: {
isAuthenticated?: boolean; isAuthenticated?: boolean;
showDashboardLink?: boolean; showDashboardLink?: boolean;
color?: string | null; color?: string | null;
} = $props(); } = $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 variant="outline" size="sm" onclick={() => (window.location.href = '/dashboard')} class="px-2 sm:px-3"> <Button
<LayoutDashboard class="w-4 h-4" /> variant="outline"
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span> size="sm"
</Button> onclick={() => (window.location.href = '/dashboard')}
{:else} class="px-2 sm:px-3"
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')} class="px-2 sm:px-3"> >
{t.auth.signIn} <LayoutDashboard class="w-4 h-4" />
</Button> <span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
{/if} </Button>
<div class="ml-auto flex items-center gap-1 sm:gap-2"> {:else}
<LanguageToggle {color} /> <Button
<ThemeToggle size="sm" {color} /> variant="outline"
</div> size="sm"
onclick={() => (window.location.href = '/signin')}
class="px-2 sm:px-3"
>
{t.auth.signIn}
</Button>
{/if}
<div class="ml-auto flex items-center gap-1 sm:gap-2">
<LanguageToggle {color} />
<ThemeToggle size="sm" {color} />
</div>
</nav> </nav>

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>

View File

@@ -1,33 +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 color
}: { }: {
themeName?: string | null; themeName?: string | null;
showTop?: boolean; showTop?: boolean;
showBottom?: boolean; showBottom?: boolean;
color?: string; color?: string;
} = $props(); } = $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}

View File

@@ -1,25 +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,
color, color,
showPattern = true showPattern = true
}: { }: {
themeName?: string | null; themeName?: string | null;
color?: string | null; color?: string | null;
showPattern?: boolean; showPattern?: boolean;
} = $props(); } = $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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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}

View File

@@ -1,65 +1,62 @@
<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 <IconButton onclick={clearColor} {color} {size} aria-label="Clear color" rounded="md">
onclick={clearColor} <X class={iconSize} />
{color} </IconButton>
{size} {/if}
aria-label="Clear color" <label
rounded="md" class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
> style={color ? `background-color: ${color};` : ''}
<X class={iconSize} /> >
</IconButton> <Pencil
{/if} class="{iconSize} relative z-10 pointer-events-none"
<label style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''}
class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden" />
style={color ? `background-color: ${color};` : ''} <input
> type="color"
<Pencil class="{iconSize} relative z-10 pointer-events-none" style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''} /> value={color || '#ffffff'}
<input oninput={handleColorChange}
type="color" class="absolute inset-0 opacity-0 cursor-pointer"
value={color || '#ffffff'} />
oninput={handleColorChange} </label>
class="absolute inset-0 opacity-0 cursor-pointer"
/>
</label>
</div> </div>

View File

@@ -1,133 +1,133 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { scale } from 'svelte/transition'; import { scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import IconButton from './IconButton.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} {#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>

View File

@@ -1,52 +1,53 @@
<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 = 'flex items-center justify-center border border-input transition-colors backdrop-blur'; const baseClasses =
const sizeClass = sizeClasses[size]; 'flex items-center justify-center border border-input transition-colors backdrop-blur';
const roundedClass = roundedClasses[rounded]; const sizeClass = sizeClasses[size];
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>

View File

@@ -1,18 +1,14 @@
<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 <Input type="search" {placeholder} bind:value />
type="search"
{placeholder}
bind:value
/>

View File

@@ -1,30 +1,27 @@
<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 <Button onclick={handleClick} variant={unlocked ? 'default' : 'outline'}>
onclick={handleClick} {#if unlocked}
variant={unlocked ? "default" : "outline"} <Lock class="mr-2 h-4 w-4" />
> {t.wishlist.lockDeletion}
{#if unlocked} {:else}
<Lock class="mr-2 h-4 w-4" /> <LockOpen class="mr-2 h-4 w-4" />
{t.wishlist.lockDeletion} {t.wishlist.unlockDeletion}
{:else} {/if}
<LockOpen class="mr-2 h-4 w-4" />
{t.wishlist.unlockDeletion}
{/if}
</Button> </Button>

View File

@@ -1,83 +1,81 @@
<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: default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', destructive:
destructive: 'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white', outline:
outline: 'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border', secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
secondary: ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80', link: 'text-primary underline-offset-4 hover:underline'
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', },
link: 'text-primary underline-offset-4 hover:underline' size: {
}, default: 'h-9 px-4 py-2 has-[>svg]:px-3',
size: { sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
default: 'h-9 px-4 py-2 has-[>svg]:px-3', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', icon: 'size-9',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', 'icon-sm': 'size-8',
icon: 'size-9', 'icon-lg': 'size-10'
'icon-sm': 'size-8', }
'icon-lg': 'size-10' },
} defaultVariants: {
}, variant: 'default',
defaultVariants: { size: 'default'
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}

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
}; };

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<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: any; 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>

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<HTMLParagraphElement> & { type Props = HTMLAttributes<HTMLParagraphElement> & {
children?: any; 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>

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<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: any; 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>

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?: any; 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>

View File

@@ -1,17 +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?: any; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<div <div class={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...restProps}>
class={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {@render children?.()}
{...restProps}
>
{@render children?.()}
</div> </div>

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
}; };

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}
/> />

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?: any; 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>

View File

@@ -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>

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>

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>

View File

@@ -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>

View File

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

View File

@@ -1,69 +1,63 @@
<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 <Button disabled variant="outline" class="w-full md:w-auto opacity-60 cursor-not-allowed">
disabled {t.wishlist.youOwnThis}
variant="outline" </Button>
class="w-full md:w-auto opacity-60 cursor-not-allowed" <p class="text-sm text-muted-foreground mt-2">
> {t.wishlist.alreadyInDashboard}
{t.wishlist.youOwnThis} </p>
</Button> {:else}
<p class="text-sm text-muted-foreground mt-2"> <form
{t.wishlist.alreadyInDashboard} method="POST"
</p> action={hasClaimed ? '?/unclaimWishlist' : '?/claimWishlist'}
{:else} use:enhance={() => {
<form return async ({ update }) => {
method="POST" await update({ reset: false });
action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"} };
use:enhance={() => { }}
return async ({ update }) => { >
await update({ reset: false }); <Button type="submit" variant={hasClaimed ? 'outline' : 'default'} class="w-full md:w-auto">
}; {hasClaimed ? 'Unclaim Wishlist' : 'Claim Wishlist'}
}} </Button>
> </form>
<Button <p class="text-sm text-muted-foreground mt-2">
type="submit" {#if hasClaimed}
variant={hasClaimed ? "outline" : "default"} You have claimed this wishlist. It will appear in your dashboard.
class="w-full md:w-auto" {:else}
> Claim this wishlist to add it to your dashboard for easy access.
{hasClaimed ? "Unclaim Wishlist" : "Claim Wishlist"} {#if isLocal}
</Button> <br />
</form> <span class="text-xs"
<p class="text-sm text-muted-foreground mt-2"> >It will remain in your local wishlists and also appear in your claimed wishlists.</span
{#if hasClaimed} >
You have claimed this wishlist. It will appear in your dashboard. {/if}
{:else} {/if}
Claim this wishlist to add it to your dashboard for easy access. </p>
{#if isLocal} {/if}
<br /> </div>
<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}

View File

@@ -1,46 +1,42 @@
<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 <Button type="submit" variant="destructive" class="w-full md:w-auto">
type="submit" {t.wishlist.deleteWishlist}
variant="destructive" </Button>
class="w-full md:w-auto" </form>
> {/if}
{t.wishlist.deleteWishlist} </div>
</Button>
</form>
{/if}
</div>
</div> </div>

View File

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

View File

@@ -1,83 +1,69 @@
<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,
onReorder, onReorder,
theme = null, theme = null,
wishlistColor = null wishlistColor = null
}: { }: {
items: Item[]; items: Item[];
rearranging: boolean; rearranging: boolean;
onStartEditing: (item: Item) => void; onStartEditing: (item: Item) => void;
onReorder: (items: Item[]) => Promise<void>; onReorder: (items: Item[]) => Promise<void>;
theme?: string | null; theme?: string | null;
wishlistColor?: string | null; wishlistColor?: string | null;
} = $props(); } = $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 <form method="POST" action="?/deleteItem" use:enhance>
method="POST" <input type="hidden" name="itemId" value={item.id} />
action="?/deleteItem" <Button type="submit" variant="destructive" size="sm">
use:enhance {t.form.delete}
> </Button>
<input </form>
type="hidden" {/if}
name="itemId" </div>
value={item.id} </WishlistItem>
/> </div>
<Button {/each}
type="submit" </div>
variant="destructive" {:else}
size="sm" <Card style={cardStyle} class="relative overflow-hidden">
> <ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
{t.form.delete} <CardContent class="p-12 relative z-10">
</Button> <EmptyState message={t.wishlist.noWishes + '. ' + t.wishlist.addFirstWish + '!'} />
</form> </CardContent>
{/if} </Card>
</div> {/if}
</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>

View File

@@ -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} {#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}

View File

@@ -1,123 +1,121 @@
<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"> <p class="text-sm text-muted-foreground">Cancel this reservation?</p>
Cancel this reservation? <div class="flex gap-2">
</p> <form
<div class="flex gap-2"> method="POST"
<form method="POST" action="?/unreserve" use:enhance={() => { action="?/unreserve"
return async ({ update }) => { use:enhance={() => {
showCancelConfirmation = false; return async ({ update }) => {
await update(); showCancelConfirmation = false;
}; await update();
}}> };
<input type="hidden" name="itemId" value={itemId} /> }}
<Button type="submit" variant="destructive" size="sm"> >
Yes, Cancel <input type="hidden" name="itemId" value={itemId} />
</Button> <Button type="submit" variant="destructive" size="sm">Yes, Cancel</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"> <Button type="submit" variant="outline" size="sm">Cancel Reservation</Button>
Cancel Reservation </form>
</Button> {/if}
</form> {/if}
{/if} </div>
{/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}

View File

@@ -1,64 +1,66 @@
<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(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : ''); const ownerLink = $derived(
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>

View File

@@ -1,25 +1,22 @@
<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 <Button onclick={onToggleAddForm} class="w-full md:w-auto">
onclick={onToggleAddForm} {showAddForm ? t.form.cancel : t.wishlist.addWish}
class="w-full md:w-auto" </Button>
>
{showAddForm ? t.form.cancel : t.wishlist.addWish}
</Button>
</div> </div>

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/db/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 wishlist.endDate ? new Date(wishlist.endDate).toISOString().split('T')[0] : null
? 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 class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]"> <div
{wishlistDescription || t.form.noDescription} class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]"
</div> >
{/if} {wishlistDescription || t.form.noDescription}
</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>

View File

@@ -1,125 +1,125 @@
<script lang="ts"> <script lang="ts">
import { Card, CardContent } from "$lib/components/ui/card"; import { Card, CardContent } from '$lib/components/ui/card';
import type { Item } from "$lib/db/schema"; import type { Item } from '$lib/db/schema';
import { GripVertical, ExternalLink } from "@lucide/svelte"; import { GripVertical, ExternalLink } from '@lucide/svelte';
import { getCardStyle } from '$lib/utils/colors'; import { getCardStyle } from '$lib/utils/colors';
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';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte'; import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
interface Props { interface Props {
item: Item; item: Item;
showImage?: boolean; showImage?: boolean;
children?: any; 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> = { const currencySymbols: Record<string, string> = {
DKK: "kr", DKK: 'kr',
EUR: "€", EUR: '€',
USD: "$", USD: '$',
SEK: "kr", SEK: 'kr',
NOK: "kr", NOK: 'kr',
GBP: "£", GBP: '£'
}; };
function formatPrice( function formatPrice(price: string | null, currency: string | null): string {
price: string | null, if (!price) return '';
currency: string | null, const symbol = currency ? currencySymbols[currency] || currency : 'kr';
): string { const amount = parseFloat(price).toFixed(2);
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 // For Danish, Swedish, Norwegian kroner, put symbol after the amount
if (currency && ["DKK", "SEK", "NOK"].includes(currency)) { if (currency && ['DKK', 'SEK', 'NOK'].includes(currency)) {
return `${amount} ${symbol}`; return `${amount} ${symbol}`;
} }
// For other currencies, put symbol before // For other currencies, put symbol before
return `${symbol}${amount}`; return `${symbol}${amount}`;
} }
const cardStyle = $derived(getCardStyle(item.color, wishlistColor)); const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
</script> </script>
<Card style={cardStyle} class="relative overflow-hidden"> <Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={item.color} showPattern={false} /> <ThemeCard themeName={theme} color={item.color} showPattern={false} />
<CardContent class="p-6 relative z-10"> <CardContent class="p-6 relative z-10">
<div class="flex gap-4"> <div class="flex gap-4">
{#if showDragHandle} {#if showDragHandle}
<div <div
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none" class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
aria-label="Drag to reorder" aria-label="Drag to reorder"
role="button" role="button"
tabindex="0" tabindex="0"
style="touch-action: none;" style="touch-action: none;"
> >
<GripVertical class="w-6 h-6 text-muted-foreground" /> <GripVertical class="w-6 h-6 text-muted-foreground" />
</div> </div>
{/if} {/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) => e.currentTarget.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 class="text-muted-foreground break-words whitespace-pre-wrap" style="overflow-wrap: anywhere;">{item.description}</p> <p
{/if} class="text-muted-foreground break-words whitespace-pre-wrap"
style="overflow-wrap: anywhere;"
>
{item.description}
</p>
{/if}
<div class="flex flex-wrap gap-2 items-center text-sm mt-2"> <div class="flex flex-wrap gap-2 items-center text-sm mt-2">
{#if item.price} {#if item.price}
<span class="font-medium" <span class="font-medium">{formatPrice(item.price, item.currency)}</span>
>{formatPrice(item.price, item.currency)}</span {/if}
> </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>
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

@@ -2,162 +2,166 @@ 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: 'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.', emptyClaimedWishlistsDescription:
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.', 'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
emptySavedWishlistsDescription: 'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.', emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
by: 'af', emptySavedWishlistsDescription:
ends: 'Slutter', 'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
welcomeBack: 'Velkommen tilbage', by: 'af',
searchPlaceholder: 'Søg ønsker...' ends: 'Slutter',
}, 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: 'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard', claimedDescription:
deleteWishlist: 'Slet Ønskeliste', 'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
deleteConfirm: 'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.', deleteWishlist: 'Slet Ønskeliste',
lockDeletion: 'Lås Sletning', deleteConfirm:
unlockDeletion: 'Lås Op for Sletning', 'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
shareViewOnly: 'Del med venner (afslører reservationer)', lockDeletion: 'Lås Sletning',
shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)', unlockDeletion: 'Lås Op for Sletning',
copy: 'Kopiér', shareViewOnly: 'Del med venner (afslører reservationer)',
copied: 'Kopieret!', shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)',
signInToSave: 'Log ind for at gemme', copy: 'Kopiér',
saveThisWishlist: 'Gem Denne Ønskeliste', copied: 'Kopieret!',
saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard', signInToSave: 'Log ind for at gemme',
creating: 'Opretter...', saveThisWishlist: 'Gem Denne Ønskeliste',
createWishlist: 'Opret Ønskeliste' saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard',
}, 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'
} }
} }
}; };

View File

@@ -1,162 +1,164 @@
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: "When someone shares an edit link with you, you can claim it to manage it from your dashboard.", emptyClaimedWishlistsDescription:
emptySavedWishlists: "You haven't saved any wishlists yet.", 'When someone shares an edit link with you, you can claim it to manage it from your dashboard.',
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.", emptySavedWishlists: "You haven't saved any wishlists yet.",
by: 'by', emptySavedWishlistsDescription:
ends: 'Ends', "When viewing someone's wishlist, you can save it to easily find it later.",
welcomeBack: 'Welcome back', by: 'by',
searchPlaceholder: 'Search wishes...' ends: 'Ends',
}, 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;

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';

View File

@@ -1 +0,0 @@

View File

@@ -1,86 +1,92 @@
export function sanitizeString(input: string | null | undefined, maxLength: number = 1000): string | null { export function sanitizeString(
if (input === null || input === undefined) { input: string | null | undefined,
return null; maxLength: number = 1000
} ): string | null {
const trimmed = input.trim(); if (input === null || input === undefined) {
if (trimmed.length > maxLength) { return null;
throw new Error(`Input exceeds maximum length of ${maxLength}`); }
} const trimmed = input.trim();
return trimmed; if (trimmed.length > maxLength) {
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(input: string | null | undefined, maxLength: number = 10000): string | null { export function sanitizeText(
if (input === null || input === undefined) { input: string | null | undefined,
return null; maxLength: number = 10000
} ): string | null {
const trimmed = input.trim(); if (input === null || input === undefined) {
if (trimmed.length > maxLength) { return null;
throw new Error(`Text exceeds maximum length of ${maxLength}`); }
} const trimmed = input.trim();
return trimmed; if (trimmed.length > maxLength) {
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;
} }

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: any = 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;
} }

View File

@@ -4,66 +4,67 @@ 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 = this.current === 'dark' || const isDark =
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); this.current === 'dark' ||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
this.resolved = isDark ? 'dark' : 'light'; this.resolved = isDark ? 'dark' : 'light';
if (isDark) { 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();

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 };

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;`;
} }

View File

@@ -5,98 +5,96 @@
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.ownerToken === ownerToken ? { ...w, isFavorite: !w.isFavorite } : w
? { ...w, isFavorite: !w.isFavorite } );
: w localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
); } catch (error) {
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated)); console.error('Failed to toggle local wishlist favorite:', error);
} catch (error) { }
console.error('Failed to toggle local wishlist favorite:', error);
}
} }

View File

@@ -3,31 +3,31 @@ import { themeStore } from '$lib/stores/theme.svelte';
export type ThemePattern = 'snow' | 'none'; export 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');
} }

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;
} }

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>

View File

@@ -1,8 +1,8 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
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
}; };
}; };

View File

@@ -1,111 +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 { 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import {
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; Card,
import { LanguageToggle } from '$lib/components/ui/language-toggle'; CardContent,
import { goto } from '$app/navigation'; CardDescription,
import ColorPicker from '$lib/components/ui/ColorPicker.svelte'; CardHeader,
import type { PageData } from './$types'; CardTitle
import { languageStore } from '$lib/stores/language.svelte'; } from '$lib/components/ui/card';
import { addLocalWishlist } from '$lib/utils/localWishlists'; import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import { goto } from '$app/navigation';
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import type { PageData } from './$types';
import { languageStore } from '$lib/stores/language.svelte';
import { addLocalWishlist } from '$lib/utils/localWishlists';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let title = $state(''); let 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() { async function createWishlist() {
if (!title.trim()) return; if (!title.trim()) return;
isCreating = true; isCreating = true;
try { try {
const response = await fetch('/api/wishlists', { const response = await fetch('/api/wishlists', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description, color }) body: JSON.stringify({ title, description, color })
}); });
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result; const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result;
// If user is not authenticated, save to localStorage // If user is not authenticated, save to localStorage
if (!data.session?.user) { if (!data.session?.user) {
addLocalWishlist({ addLocalWishlist({
ownerToken, ownerToken,
publicToken, publicToken,
title: wishlistTitle, title: wishlistTitle,
createdAt createdAt
}); });
} }
goto(`/wishlist/${ownerToken}/edit`); goto(`/wishlist/${ownerToken}/edit`);
} }
} catch (error) { } catch (error) {
console.error('Failed to create wishlist:', error); console.error('Failed to create wishlist:', error);
} finally { } finally {
isCreating = false; 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 onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4"> <form
<div class="space-y-2"> onsubmit={(e) => {
<Label for="title">{t.form.wishlistTitle}</Label> e.preventDefault();
<Input createWishlist();
id="title" }}
bind:value={title} class="space-y-4"
placeholder={t.form.wishlistTitlePlaceholder} >
required <div class="space-y-2">
/> <Label for="title">{t.form.wishlistTitle}</Label>
</div> <Input
<div class="space-y-2"> id="title"
<Label for="description">{t.form.descriptionOptional}</Label> bind:value={title}
<Textarea placeholder={t.form.wishlistTitlePlaceholder}
id="description" required
bind:value={description} />
placeholder={t.form.descriptionPlaceholder} </div>
rows={3} <div class="space-y-2">
/> <Label for="description">{t.form.descriptionOptional}</Label>
</div> <Textarea
<div class="space-y-2"> id="description"
<div class="flex items-center justify-between"> bind:value={description}
<Label for="color">{t.form.wishlistColor}</Label> placeholder={t.form.descriptionPlaceholder}
<ColorPicker bind:color={color} size="sm" /> rows={3}
</div> />
</div> </div>
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}> <div class="space-y-2">
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist} <div class="flex items-center justify-between">
</Button> <Label for="color">{t.form.wishlistColor}</Label>
</form> <ColorPicker bind:color size="sm" />
</CardContent> </div>
</Card> </div>
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
</Button>
</form>
</CardContent>
</Card>
</div> </div>

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 });
} }
}; };

View File

@@ -2,220 +2,241 @@ 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': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', Accept:
'Accept-Language': 'en-US,en;q=0.9', 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Encoding': 'gzip, deflate, br', 'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache', 'Accept-Encoding': 'gzip, deflate, br',
'Pragma': 'no-cache' 'Cache-Control': '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', 'icon', 'sprite', 'favicon', 'banner', 'footer', 'logo',
'header', 'background', 'pattern', 'placeholder', 'thumbnail-small', 'icon',
'btn', 'button', 'menu', 'nav', 'navigation', 'social', 'sprite',
'instagram', 'facebook', 'twitter', 'linkedin', 'pinterest' 'favicon',
]; 'banner',
if (badPatterns.some(pattern => lower.includes(pattern))) { 'footer',
return false; 'header',
} 'background',
if (url.endsWith('.svg')) { 'pattern',
return false; 'placeholder',
} 'thumbnail-small',
if (lower.includes('data:image')) { 'btn',
return false; 'button',
} 'menu',
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) { 'nav',
return false; 'navigation',
} 'social',
return true; 'instagram',
} 'facebook',
'twitter',
'linkedin',
'pinterest'
];
if (badPatterns.some((pattern) => lower.includes(pattern))) {
return false;
}
if (url.endsWith('.svg')) {
return false;
}
if (lower.includes('data:image')) {
return false;
}
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
return false;
}
return true;
}
let match; let match;
// Priority 1: OpenGraph and Twitter meta tags (main product image) // 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 = /<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi; const twitterImageRegex =
/<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 = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; const jsonLdRegex =
while ((match = jsonLdRegex.exec(html)) !== null) { /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
try { while ((match = jsonLdRegex.exec(html)) !== null) {
const jsonStr = match[1]; try {
const jsonData = JSON.parse(jsonStr); const jsonStr = match[1];
const jsonData = JSON.parse(jsonStr);
function extractImages(obj: any, 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: any) => extractImages(item, results)); obj.forEach((item: any) => extractImages(item, results));
} else { } else {
for (const key in obj) { for (const key in obj) {
if (key === 'image' || key === 'thumbnail' || key === 'url') { if (key === 'image' || key === 'thumbnail' || key === 'url') {
const val = obj[key]; const val = obj[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: any) => {
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 obj[key] === 'object') {
extractImages(obj[key], results); extractImages(obj[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 (error) { } catch (error) {
return json({ error: 'Failed to scrape images' }, { status: 500 }); return json({ error: 'Failed to scrape images' }, { status: 500 });
} }
}; };

View File

@@ -5,34 +5,31 @@ import { wishlists } from '$lib/db/schema';
import { eq, or } from 'drizzle-orm'; 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( where: or(eq(wishlists.ownerToken, token), eq(wishlists.publicToken, token)),
eq(wishlists.ownerToken, token), with: {
eq(wishlists.publicToken, token) items: {
), 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 || []
}); });
}; };

View File

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

View File

@@ -5,190 +5,194 @@ import { wishlists, savedWishlists, users } from '$lib/db/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, ? {
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves) ...s.wishlist,
ownerToken: s.ownerToken, // Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
// Keep publicToken as-is for viewing ownerToken: s.ownerToken,
publicToken: s.wishlist.publicToken // Keep publicToken as-is for viewing
} : null publicToken: s.wishlist.publicToken
})); }
: 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.update(wishlists) await db
.set({ isFavorite: !isFavorite, updatedAt: new Date() }) .update(wishlists)
.where(eq(wishlists.id, wishlistId)); .set({ isFavorite: !isFavorite, updatedAt: new Date() })
.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.update(savedWishlists) await db
.set({ isFavorite: !isFavorite }) .update(savedWishlists)
.where(eq(savedWishlists.id, savedWishlistId)); .set({ isFavorite: !isFavorite })
.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.delete(savedWishlists) await db
.where(and( .delete(savedWishlists)
eq(savedWishlists.id, savedWishlistId), .where(
eq(savedWishlists.userId, session.user.id) and(eq(savedWishlists.id, savedWishlistId), 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.delete(wishlists) await db
.where(and( .delete(wishlists)
eq(wishlists.id, wishlistId), .where(and(eq(wishlists.id, wishlistId), eq(wishlists.userId, session.user.id)));
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.update(users) await db
.set({ dashboardTheme: theme, updatedAt: new Date() }) .update(users)
.where(eq(users.id, session.user.id)); .set({ dashboardTheme: theme, updatedAt: new Date() })
.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.update(users) await db
.set({ dashboardColor: color, updatedAt: new Date() }) .update(users)
.where(eq(users.id, session.user.id)); .set({ dashboardColor: color, updatedAt: new Date() })
.where(eq(users.id, session.user.id));
return { success: true }; return { success: true };
} }
}; };

View File

@@ -1,255 +1,279 @@
<script lang="ts"> <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 method="POST" action="?/toggleFavorite" use:enhance={() => { <form
return async ({ update }) => { method="POST"
await update({ reset: false }); action="?/toggleFavorite"
}; use:enhance={() => {
}}> return async ({ update }) => {
<input type="hidden" name="wishlistId" value={wishlist.id} /> await update({ reset: false });
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} /> };
<Button type="submit" size="sm" variant="outline"> }}
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} /> >
</Button> <input type="hidden" name="wishlistId" value={wishlist.id} />
</form> <input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
<Button <Button type="submit" size="sm" variant="outline">
size="sm" <Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)} </Button>
> </form>
{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}` size="sm"
); variant="outline"
}} onclick={() => {
> navigator.clipboard.writeText(
{t.dashboard.copyLink} `${window.location.origin}/wishlist/${wishlist.publicToken}`
</Button> );
{#if unlocked} }}
<form method="POST" action="?/deleteWishlist" use:enhance={() => { >
return async ({ update }) => { {t.dashboard.copyLink}
await update({ reset: false }); </Button>
}; {#if unlocked}
}}> <form
<input type="hidden" name="wishlistId" value={wishlist.id} /> method="POST"
<Button type="submit" size="sm" variant="destructive"> action="?/deleteWishlist"
{t.dashboard.delete} use:enhance={() => {
</Button> return async ({ update }) => {
</form> await update({ reset: false });
{/if} };
</div> }}
{/snippet} >
</WishlistSection> <input type="hidden" name="wishlistId" value={wishlist.id} />
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.delete}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<LocalWishlistsSection <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 method="POST" action="?/toggleSavedFavorite" use:enhance={() => { <form
return async ({ update }) => { method="POST"
await update({ reset: false }); action="?/toggleSavedFavorite"
}; use:enhance={() => {
}}> return async ({ update }) => {
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} /> await update({ reset: false });
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} /> };
<Button type="submit" size="sm" variant="outline"> }}
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} /> >
</Button> <input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
</form> <input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
<Button <Button type="submit" size="sm" variant="outline">
size="sm" <Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)} </Button>
> </form>
{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}` size="sm"
); variant="outline"
}} onclick={() => {
> navigator.clipboard.writeText(
{t.dashboard.copyLink} `${window.location.origin}/wishlist/${wishlist.publicToken}`
</Button> );
{#if unlocked} }}
<form method="POST" action="?/unsaveWishlist" use:enhance={() => { >
return async ({ update }) => { {t.dashboard.copyLink}
await update({ reset: false }); </Button>
}; {#if unlocked}
}}> <form
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} /> method="POST"
<Button type="submit" size="sm" variant="destructive"> action="?/unsaveWishlist"
{t.dashboard.unclaim} use:enhance={() => {
</Button> return async ({ update }) => {
</form> await update({ reset: false });
{/if} };
</div> }}
{/snippet} >
</WishlistSection> <input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
<Button type="submit" size="sm" variant="destructive">
{t.dashboard.unclaim}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
<WishlistSection <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 method="POST" action="?/toggleSavedFavorite" use:enhance={() => { <form
return async ({ update }) => { method="POST"
await update({ reset: false }); action="?/toggleSavedFavorite"
}; use:enhance={() => {
}}> return async ({ update }) => {
<input type="hidden" name="savedWishlistId" value={saved.id} /> await update({ reset: false });
<input type="hidden" name="isFavorite" value={saved.isFavorite} /> };
<Button type="submit" size="sm" variant="outline"> }}
<Star class={saved.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} /> >
</Button> <input type="hidden" name="savedWishlistId" value={saved.id} />
</form> <input type="hidden" name="isFavorite" value={saved.isFavorite} />
<Button <Button type="submit" size="sm" variant="outline">
size="sm" <Star class={saved.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)} </Button>
> </form>
{t.dashboard.viewWishlist} <Button
</Button> size="sm"
{#if unlocked} onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)}
<form method="POST" action="?/unsaveWishlist" use:enhance={() => { >
return async ({ update }) => { {t.dashboard.viewWishlist}
await update({ reset: false }); </Button>
}; {#if unlocked}
}}> <form
<input type="hidden" name="savedWishlistId" value={saved.id} /> method="POST"
<Button type="submit" size="sm" variant="destructive"> action="?/unsaveWishlist"
{t.dashboard.unsave} use:enhance={() => {
</Button> return async ({ update }) => {
</form> await update({ reset: false });
{/if} };
</div> }}
{/snippet} >
</WishlistSection> <input type="hidden" name="savedWishlistId" value={saved.id} />
{/if} <Button type="submit" size="sm" variant="destructive">
{t.dashboard.unsave}
</Button>
</form>
{/if}
</div>
{/snippet}
</WishlistSection>
{/if}
</PageContainer> </PageContainer>

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
}; };
}; };

View File

@@ -1,107 +1,117 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import {
import { Input } from '$lib/components/ui/input'; Card,
import { Label } from '$lib/components/ui/label'; CardContent,
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; CardDescription,
import { LanguageToggle } from '$lib/components/ui/language-toggle'; CardHeader,
import type { PageData } from './$types'; CardTitle
import { signIn } from '@auth/sveltekit/client'; } from '$lib/components/ui/card';
import { languageStore } from '$lib/stores/language.svelte'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let isSubmitting = $state(false); let isSubmitting = $state(false);
async function handleSubmit(e: SubmitEvent) { 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 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"> <div
Account created successfully! Please sign in. 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"
</div> >
{/if} Account created successfully! Please sign in.
</div>
{/if}
{#if data.error} {#if data.error}
<div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded"> <div
Invalid username or password 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"
</div> >
{/if} Invalid username or password
</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} {#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>

View File

@@ -8,68 +8,68 @@ 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 (error) { } 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);
await db.insert(users).values({ await db.insert(users).values({
name: sanitizedName, name: sanitizedName,
username: sanitizedUsername, username: sanitizedUsername,
password: hashedPassword password: hashedPassword
}); });
throw redirect(303, '/signin?registered=true'); throw redirect(303, '/signin?registered=true');
} }
}; };

View File

@@ -1,86 +1,100 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card'; import {
import { Input } from '$lib/components/ui/input'; Card,
import { Label } from '$lib/components/ui/label'; CardContent,
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; CardDescription,
import { LanguageToggle } from '$lib/components/ui/language-toggle'; CardHeader,
import type { ActionData, PageData } from './$types'; CardTitle
import { signIn } from '@auth/sveltekit/client'; } from '$lib/components/ui/card';
import { languageStore } from '$lib/stores/language.svelte'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle';
import type { ActionData, PageData } from './$types';
import { signIn } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte';
let { form, data }: { form: ActionData; data: PageData } = $props(); let { form, data }: { form: ActionData; data: PageData } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
</script> </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 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"> <div
{form.error} 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"
</div> >
{/if} {form.error}
</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 id="confirmPassword" name="confirmPassword" type="password" required minlength={8} /> <Input
</div> id="confirmPassword"
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} {#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>

View File

@@ -5,182 +5,173 @@ import { wishlists, items, reservations, savedWishlists } from '$lib/db/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 await tx.update(items).set({ isReserved: true }).where(eq(items.id, itemId));
.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 await tx.update(items).set({ isReserved: false }).where(eq(items.id, itemId));
.update(items) });
.set({ isReserved: false })
.where(eq(items.id, itemId));
});
return { success: true }; return { success: true };
}, },
saveWishlist: async ({ request, locals, params }) => { 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
await db.insert(savedWishlists).values({ 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
}); });
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( and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id))
eq(savedWishlists.id, savedWishlistId), );
eq(savedWishlists.userId, session.user.id)
)
);
return { success: true }; return { success: true };
} }
}; };

View File

@@ -1,137 +1,128 @@
<script lang="ts"> <script lang="ts">
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle
} from "$lib/components/ui/card"; } from '$lib/components/ui/card';
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 WishlistItem from "$lib/components/wishlist/WishlistItem.svelte"; import WishlistItem from '$lib/components/wishlist/WishlistItem.svelte';
import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte"; import ReservationButton from '$lib/components/wishlist/ReservationButton.svelte';
import PageContainer from "$lib/components/layout/PageContainer.svelte"; import PageContainer from '$lib/components/layout/PageContainer.svelte';
import Navigation from "$lib/components/layout/Navigation.svelte"; import Navigation from '$lib/components/layout/Navigation.svelte';
import EmptyState from "$lib/components/layout/EmptyState.svelte"; import EmptyState from '$lib/components/layout/EmptyState.svelte';
import { enhance } from "$app/forms"; import { enhance } from '$app/forms';
import { getCardStyle } from "$lib/utils/colors"; import { getCardStyle } from '$lib/utils/colors';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import SearchBar from "$lib/components/ui/SearchBar.svelte"; import SearchBar from '$lib/components/ui/SearchBar.svelte';
import ThemeCard from "$lib/components/themes/ThemeCard.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(item => data.wishlist.items?.filter(
item.title.toLowerCase().includes(searchQuery.toLowerCase()) || (item) =>
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) item.title.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" <CardDescription class="text-base">{data.wishlist.description}</CardDescription>
>{data.wishlist.description}</CardDescription {/if}
> </div>
{/if} {#if data.isAuthenticated}
</div> {#if data.isClaimed}
{#if data.isAuthenticated} <Button variant="outline" size="sm" disabled>
{#if data.isClaimed} {t.wishlist.youClaimedThis}
<Button </Button>
variant="outline" {:else if data.isSaved}
size="sm" <form method="POST" action="?/unsaveWishlist" use:enhance>
disabled <input type="hidden" name="savedWishlistId" value={data.savedWishlistId} />
> <Button type="submit" variant="outline" size="sm">
{t.wishlist.youClaimedThis} {t.wishlist.unsaveWishlist}
</Button> </Button>
{:else if data.isSaved} </form>
<form method="POST" action="?/unsaveWishlist" use:enhance> {:else}
<input <form
type="hidden" method="POST"
name="savedWishlistId" action="?/saveWishlist"
value={data.savedWishlistId} use:enhance={() => {
/> return async ({ update }) => {
<Button type="submit" variant="outline" size="sm"> await update({ reset: false });
{t.wishlist.unsaveWishlist} };
</Button> }}
</form> >
{:else} <input type="hidden" name="wishlistId" value={data.wishlist.id} />
<form method="POST" action="?/saveWishlist" use:enhance={() => { <Button type="submit" variant="outline" size="sm">
return async ({ update }) => { {t.wishlist.saveWishlist}
await update({ reset: false }); </Button>
}; </form>
}}> {/if}
<input type="hidden" name="wishlistId" value={data.wishlist.id} /> {:else}
<Button type="submit" variant="outline" size="sm"> <Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')}>
{t.wishlist.saveWishlist} {t.wishlist.signInToSave}
</Button> </Button>
</form> {/if}
{/if} </div>
{:else} </CardContent>
<Button </Card>
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} {#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 <EmptyState message="No wishes match your search." />
message="No wishes match your search." </CardContent>
/> </Card>
</CardContent> {:else}
</Card> <Card style={headerCardStyle} class="relative overflow-hidden">
{:else} <ThemeCard
<Card style={headerCardStyle} class="relative overflow-hidden"> themeName={data.wishlist.theme}
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} showPattern={false} /> color={data.wishlist.color}
<CardContent class="p-12 relative z-10"> showPattern={false}
<EmptyState />
message={t.wishlist.emptyWishes} <CardContent class="p-12 relative z-10">
/> <EmptyState message={t.wishlist.emptyWishes} />
</CardContent> </CardContent>
</Card> </Card>
{/if} {/if}
</div> </div>
</PageContainer> </PageContainer>

View File

@@ -5,310 +5,309 @@ import { wishlists, items, savedWishlists } from '$lib/db/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.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 title = formData.get('title') as string; const title = formData.get('title') as string;
const description = formData.get('description') as string; const description = formData.get('description') as string;
const link = formData.get('link') as string; const link = formData.get('link') as string;
const imageUrl = formData.get('imageUrl') as string; const imageUrl = formData.get('imageUrl') as string;
const price = formData.get('price') as string; const price = formData.get('price') as string;
const currency = formData.get('currency') as string; const currency = formData.get('currency') as string;
const color = formData.get('color') as string; const color = formData.get('color') as string;
if (!title?.trim()) { if (!title?.trim()) {
return { success: false, error: 'Title is required' }; return { success: false, error: 'Title is required' };
} }
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: true items: true
} }
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
// Get the max order value and add 1 // Get the max order value and add 1
const maxOrder = wishlist.items.reduce((max, item) => { const maxOrder = wishlist.items.reduce((max, item) => {
const order = Number(item.order) || 0; const order = Number(item.order) || 0;
return order > max ? order : max; return order > max ? order : max;
}, 0); }, 0);
await db.insert(items).values({ await db.insert(items).values({
wishlistId: wishlist.id, wishlistId: wishlist.id,
title: title.trim(), title: title.trim(),
description: description?.trim() || null, description: description?.trim() || null,
link: link?.trim() || null, link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null, imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null, price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK', currency: currency?.trim() || 'DKK',
color: color?.trim() || null, color: color?.trim() || null,
order: String(maxOrder + 1) order: String(maxOrder + 1)
}); });
return { success: true }; return { success: true };
}, },
updateItem: async ({ params, request }) => { updateItem: async ({ params, request }) => {
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 title = formData.get('title') as string; const title = formData.get('title') as string;
const description = formData.get('description') as string; const description = formData.get('description') as string;
const link = formData.get('link') as string; const link = formData.get('link') as string;
const imageUrl = formData.get('imageUrl') as string; const imageUrl = formData.get('imageUrl') as string;
const price = formData.get('price') as string; const price = formData.get('price') as string;
const currency = formData.get('currency') as string; const currency = formData.get('currency') as string;
const color = formData.get('color') as string; const color = formData.get('color') as string;
if (!itemId) { if (!itemId) {
return { success: false, error: 'Item ID is required' }; return { success: false, error: 'Item ID is required' };
} }
if (!title?.trim()) { if (!title?.trim()) {
return { success: false, error: 'Title is required' }; return { success: false, error: 'Title is required' };
} }
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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
await db.update(items) await db
.set({ .update(items)
title: title.trim(), .set({
description: description?.trim() || null, title: title.trim(),
link: link?.trim() || null, description: description?.trim() || null,
imageUrl: imageUrl?.trim() || null, link: link?.trim() || null,
price: price ? price.trim() : null, imageUrl: imageUrl?.trim() || null,
currency: currency?.trim() || 'DKK', price: price ? price.trim() : null,
color: color?.trim() || null, currency: currency?.trim() || 'DKK',
updatedAt: new Date() color: color?.trim() || null,
}) updatedAt: new Date()
.where(eq(items.id, itemId)); })
.where(eq(items.id, itemId));
return { success: true }; return { success: true };
}, },
deleteItem: async ({ params, request }) => { deleteItem: async ({ params, request }) => {
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 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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
await db.delete(items).where(eq(items.id, itemId)); await db.delete(items).where(eq(items.id, itemId));
return { success: true }; return { success: true };
}, },
reorderItems: async ({ params, request }) => { reorderItems: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const itemsJson = formData.get('items') as string; const itemsJson = formData.get('items') as string;
if (!itemsJson) { if (!itemsJson) {
return { success: false, error: 'Items data is required' }; return { success: false, error: 'Items data is required' };
} }
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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>; const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
for (const update of updates) { for (const update of updates) {
await db.update(items) await db
.set({ order: String(update.order), updatedAt: new Date() }) .update(items)
.where(eq(items.id, update.id)); .set({ order: String(update.order), updatedAt: new Date() })
} .where(eq(items.id, update.id));
}
return { success: true }; return { success: true };
}, },
deleteWishlist: async ({ params }) => { deleteWishlist: async ({ params }) => {
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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id)); await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
return { success: true, redirect: '/dashboard' }; return { success: true, redirect: '/dashboard' };
}, },
updateWishlist: async ({ params, request }) => { updateWishlist: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const color = formData.get('color'); const color = formData.get('color');
const title = formData.get('title'); const title = formData.get('title');
const description = formData.get('description'); const description = formData.get('description');
const endDate = formData.get('endDate'); const endDate = formData.get('endDate');
const theme = formData.get('theme'); const theme = formData.get('theme');
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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const updates: any = { const updates: any = {
updatedAt: new Date() updatedAt: new Date()
}; };
if (color !== null) { if (color !== null) {
updates.color = color?.toString().trim() || null; updates.color = color?.toString().trim() || null;
} }
if (title !== null) { if (title !== null) {
const titleStr = title?.toString().trim(); const titleStr = title?.toString().trim();
if (!titleStr) { if (!titleStr) {
return { success: false, error: 'Title is required' }; return { success: false, error: 'Title is required' };
} }
updates.title = titleStr; updates.title = titleStr;
} }
if (description !== null) { if (description !== null) {
updates.description = description?.toString().trim() || null; updates.description = description?.toString().trim() || null;
} }
if (endDate !== null) { if (endDate !== null) {
const endDateStr = endDate?.toString().trim(); const endDateStr = endDate?.toString().trim();
updates.endDate = endDateStr ? new Date(endDateStr) : null; updates.endDate = endDateStr ? new Date(endDateStr) : null;
} }
if (theme !== null) { if (theme !== null) {
updates.theme = theme?.toString().trim() || 'none'; updates.theme = theme?.toString().trim() || 'none';
} }
await db.update(wishlists) await db.update(wishlists).set(updates).where(eq(wishlists.id, wishlist.id));
.set(updates)
.where(eq(wishlists.id, wishlist.id));
return { success: true }; return { success: true };
}, },
claimWishlist: async ({ params, locals }) => { claimWishlist: async ({ params, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw error(401, 'You must be signed in to claim a wishlist'); throw error(401, 'You must be signed in to claim a wishlist');
} }
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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
// Check if already claimed // Check if already claimed
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, wishlist.id) eq(savedWishlists.wishlistId, wishlist.id)
) )
}); });
if (existing) { if (existing) {
return { success: true, message: 'Already claimed' }; return { success: true, message: 'Already claimed' };
} }
// Store the ownerToken - user is accessing via edit link, so they get edit access // Store the ownerToken - user is accessing via edit link, so they get edit access
await db.insert(savedWishlists).values({ await db.insert(savedWishlists).values({
userId: session.user.id, userId: session.user.id,
wishlistId: wishlist.id, wishlistId: wishlist.id,
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
isFavorite: false isFavorite: false
}); });
return { success: true, message: 'Wishlist claimed successfully' }; return { success: true, message: 'Wishlist claimed successfully' };
}, },
unclaimWishlist: async ({ params, locals }) => { unclaimWishlist: async ({ params, locals }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
throw error(401, 'You must be signed in'); throw error(401, 'You must be signed in');
} }
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)
}); });
if (!wishlist) { if (!wishlist) {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
await db.delete(savedWishlists).where( await db
and( .delete(savedWishlists)
eq(savedWishlists.userId, session.user.id), .where(
eq(savedWishlists.wishlistId, wishlist.id) and(eq(savedWishlists.userId, session.user.id), eq(savedWishlists.wishlistId, wishlist.id))
) );
);
return { success: true, message: 'Wishlist unclaimed' }; return { success: true, message: 'Wishlist unclaimed' };
} }
}; };

View File

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

View File

@@ -3,22 +3,22 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
// Consult https://svelte.dev/docs/kit/integrations // Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { kit: {
adapter: adapter({ adapter: adapter({
out: 'build' out: 'build'
}), }),
alias: { alias: {
'$db': './src/lib/db', $db: './src/lib/db',
'$components': './src/lib/components', $components: './src/lib/components',
'$utils': './src/lib/utils', $utils: './src/lib/utils',
'$stores': './src/lib/stores', $stores: './src/lib/stores',
'$i18n': './src/lib/i18n' $i18n: './src/lib/i18n'
} }
} }
}; };
export default config; export default config;

View File

@@ -1,20 +1,20 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "rewriteRelativeImportExtensions": true,
"allowJs": true, "allowJs": true,
"checkJs": true, "checkJs": true,
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"skipLibCheck": true, "skipLibCheck": true,
"sourceMap": true, "sourceMap": true,
"strict": true, "strict": true,
"moduleResolution": "bundler" "moduleResolution": "bundler"
} }
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
// To make changes to top-level options such as include and exclude, we recommend extending // To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript // the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }

View File

@@ -3,5 +3,5 @@ import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()] plugins: [tailwindcss(), sveltekit()]
}); });