Compare commits

..
105 changed files with 4915 additions and 5669 deletions
-3
View File
@@ -24,6 +24,3 @@ vite.config.ts.timestamp-*
# Claude # Claude
.claude .claude
# Infisical
.infisical*
-8
View File
@@ -1,8 +0,0 @@
{
"version": "2",
"phaseApp": "wishlist",
"appId": "a4d85c7a-8df9-462b-9b91-5cb2957cdcd3",
"defaultEnv": "Production",
"envId": "496d0105-f2b4-424d-a1a1-a60602fc2252",
"monorepoSupport": false
}
-15
View File
@@ -1,15 +0,0 @@
# Ignore files for Prettier
build
.svelte-kit
dist
node_modules
# Ignore generated files
drizzle/meta
# Ignore lock files
bun.lock
package-lock.json
yarn.lock
pnpm-lock.yaml
-9
View File
@@ -1,9 +0,0 @@
{
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
+84
View File
@@ -0,0 +1,84 @@
# Coolify Deployment
## Prerequisites
- Coolify instance
- Git repository
## Database Setup
Choose one:
**Option A: Docker Compose (Recommended)**
- Database included in `docker-compose.coolify.yml`
- Skip to step 2
**Option B: Separate PostgreSQL Resource**
1. Create PostgreSQL database in Coolify
2. Note connection details
## Deploy
### Using Docker Compose (Recommended)
1. Create application in Coolify
2. Select Git repository
3. Configure:
- Build Pack: Docker Compose
- File: `./docker-compose.coolify.yml`
4. Assign domain to `app` service only (format: `http://yourdomain.com:3000`)
5. Set environment variables:
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
- `AUTH_URL` (your domain with https)
- `POSTGRES_DATA_PATH` (optional, for custom database storage location)
- `GOOGLE_CLIENT_ID` (optional)
- `GOOGLE_CLIENT_SECRET` (optional)
6. Deploy
### Using Dockerfile
1. Create application in Coolify
2. Select Git repository
3. Configure:
- Build Pack: Docker
- Port: `3000`
4. Add domain
5. Set environment variables:
- `DATABASE_URL`
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
- `AUTH_URL` (your domain with https)
- `GOOGLE_CLIENT_ID` (optional)
- `GOOGLE_CLIENT_SECRET` (optional)
6. Deploy
## After Deployment
Run migrations:
```bash
# In Coolify terminal or SSH
docker exec -it <container-name> bun run db:push
```
## Environment Variables
Required:
- `DATABASE_URL` - Connection string
- `AUTH_SECRET` - Random secret
- `AUTH_URL` - Your app URL
- `AUTH_TRUST_HOST` - `true`
Optional:
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `POSTGRES_DATA_PATH` - Custom path for PostgreSQL data (Docker Compose only)
- Example: `/mnt/storage/wishlist/postgres`
- If not set, uses a Docker named volume
- Path must exist with proper permissions before deployment
## Troubleshooting
**Container crashes:** Check logs in Coolify dashboard
**Database connection:** Verify `DATABASE_URL` format
**Auth issues:** Check `AUTH_URL` matches your domain
-35
View File
@@ -1,35 +0,0 @@
# Wishlist Production Deployment
Uses Phase for secrets management.
## Required Secrets
Ensure these exist in your Phase project under the `Production` environment:
- `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` - Database credentials
- `AUTH_SECRET` - Auth.js secret (generate: `openssl rand -base64 32`)
- `AUTH_URL` - Public URL (e.g., `https://wish.rasmusq.com`)
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` - (Optional) Google OAuth
- `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET`, `AUTHENTIK_ISSUER` - (Optional) Authentik OAuth
## Common Commands
```bash
# Deploy/rebuild
phase run docker compose -f docker-compose.prod.yml up -d --build
# View logs
docker compose -f docker-compose.prod.yml logs -f
# Restart
phase run docker compose -f docker-compose.prod.yml restart
# Stop
phase run docker compose -f docker-compose.prod.yml down
# Database migrations
phase run bun run db:migrate
# Development
phase run bun run dev
```
-3
View File
@@ -35,13 +35,11 @@ docker run -d \
## Environment Variables ## Environment Variables
Required: Required:
- `DATABASE_URL` - PostgreSQL connection string - `DATABASE_URL` - PostgreSQL connection string
- `NODE_ENV` - Set to `production` - `NODE_ENV` - Set to `production`
- `PORT` - Default `3000` - `PORT` - Default `3000`
Optional (Docker Compose): Optional (Docker Compose):
- `POSTGRES_USER` - Database user (default: `wishlistuser`) - `POSTGRES_USER` - Database user (default: `wishlistuser`)
- `POSTGRES_PASSWORD` - Database password (default: `wishlistpassword`) - `POSTGRES_PASSWORD` - Database password (default: `wishlistpassword`)
- `POSTGRES_DB` - Database name (default: `wishlist`) - `POSTGRES_DB` - Database name (default: `wishlist`)
@@ -63,7 +61,6 @@ docker exec -it wishlist-app bun run db:push
## Migrations ## Migrations
Production migrations: Production migrations:
```bash ```bash
docker exec -it wishlist-app bun run db:migrate docker exec -it wishlist-app bun run db:migrate
``` ```
+1 -1
View File
@@ -32,7 +32,7 @@ COPY --from=deps /app/node_modules ./node_modules
# Copy Drizzle files for migrations # Copy Drizzle files for migrations
COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./ COPY --from=builder /app/drizzle.config.ts ./
COPY --from=builder /app/src/lib/db ./src/lib/db COPY --from=builder /app/src/lib/server/schema.ts ./src/lib/server/schema.ts
# Expose the port # Expose the port
EXPOSE 3000 EXPOSE 3000
-3
View File
@@ -2,9 +2,6 @@
A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL. A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL.
![Dashboard](readme-assets/dashboard.png)
![Creating a wishlist](readme-assets/create.png)
## Prerequisites ## Prerequisites
- [Bun](https://bun.sh/) - [Bun](https://bun.sh/)
-5
View File
@@ -16,7 +16,6 @@ Visit `http://localhost:3000`
Choose one: Choose one:
**Local PostgreSQL:** **Local PostgreSQL:**
```bash ```bash
sudo apt install postgresql sudo apt install postgresql
sudo -u postgres createdb wishlist sudo -u postgres createdb wishlist
@@ -27,7 +26,6 @@ GRANT ALL PRIVILEGES ON DATABASE wishlist TO wishlistuser;
``` ```
**Docker PostgreSQL:** **Docker PostgreSQL:**
```bash ```bash
docker run --name wishlist-postgres \ docker run --name wishlist-postgres \
-e POSTGRES_DB=wishlist \ -e POSTGRES_DB=wishlist \
@@ -59,18 +57,15 @@ Visit `http://localhost:5173`
## Troubleshooting ## Troubleshooting
**Connection errors:** **Connection errors:**
- Check PostgreSQL is running: `sudo systemctl status postgresql` - Check PostgreSQL is running: `sudo systemctl status postgresql`
- Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"` - Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"`
**Port in use:** **Port in use:**
```bash ```bash
bun run dev -- --port 3000 bun run dev -- --port 3000
``` ```
**Schema changes:** **Schema changes:**
```bash ```bash
bun run db:push bun run db:push
``` ```
+3 -207
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1,
"workspaces": { "workspaces": {
"": { "": {
"name": "wishlist-app", "name": "wishlist-app",
@@ -13,13 +14,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",
@@ -28,20 +29,13 @@
"@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",
"svelte-eslint-parser": "^1.6.0",
"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",
}, },
}, },
@@ -111,38 +105,12 @@
"@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=="],
@@ -269,58 +237,26 @@
"@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=="],
@@ -331,8 +267,6 @@
"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=="],
@@ -347,18 +281,12 @@
"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=="],
@@ -389,52 +317,18 @@
"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=="],
@@ -447,10 +341,6 @@
"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=="],
@@ -463,22 +353,12 @@
"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=="],
@@ -495,30 +375,16 @@
"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=="],
@@ -543,13 +409,9 @@
"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=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lucide-svelte": ["lucide-svelte@0.554.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-LLcpHi3SuKup0nVD1kKqo8FDZnjXJp48uST26GGh8Jcyrxqk5gmgpnvKmHsHox674UL3cPS1DCul/wFL7ybGqg=="],
"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=="],
@@ -559,8 +421,6 @@
"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=="],
@@ -571,8 +431,6 @@
"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=="],
@@ -583,18 +441,8 @@
"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=="],
@@ -605,14 +453,6 @@
"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=="],
@@ -621,22 +461,12 @@
"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=="],
@@ -665,8 +495,6 @@
"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=="],
@@ -679,8 +507,6 @@
"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=="],
@@ -701,38 +527,24 @@
"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=="],
@@ -741,10 +553,6 @@
"@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=="],
@@ -759,16 +567,8 @@
"@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=="],
@@ -828,9 +628,5 @@
"@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=="],
} }
} }
+51
View File
@@ -0,0 +1,51 @@
# Coolify-optimized Docker Compose
# Includes both app and database - database is only exposed internally
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${POSTGRES_USER:-wishlistuser}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wishlistpassword}
POSTGRES_DB: ${POSTGRES_DB:-wishlist}
volumes:
- type: bind
source: ${POSTGRES_DATA_PATH}
target: /var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wishlistuser} -d ${POSTGRES_DB:-wishlist}"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# NOTE: No ports exposed - only accessible internally by app service
app:
build:
context: .
dockerfile: Dockerfile
environment:
# Coolify will inject these from Environment Variables
DATABASE_URL: postgresql://${POSTGRES_USER:-wishlistuser}:${POSTGRES_PASSWORD:-wishlistpassword}@db:5432/${POSTGRES_DB:-wishlist}
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL:-https://wish.rasmusq.com}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
depends_on:
db:
condition: service_healthy
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
- traefik.http.routers.wishlist.entryPoints=https
- traefik.http.routers.wishlist.tls=true
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
- traefik.http.services.wishlist.loadbalancer.server.port=3000
# Forward headers for Auth.js behind reverse proxy
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Proto=https
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Host=wish.rasmusq.com
- traefik.http.routers.wishlist.middlewares=wishlist-headers
-21
View File
@@ -1,21 +0,0 @@
services:
database:
image: postgres:16-alpine
container_name: wishlist-postgres-test
restart: unless-stopped
ports:
- 5432:5432
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
-62
View File
@@ -1,62 +0,0 @@
services:
database:
image: postgres:16-alpine
container_name: wishlist-postgres
restart: unless-stopped
pull_policy: always
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
networks:
- wishlist-net
app:
build:
context: .
dockerfile: Dockerfile
container_name: wishlist-app
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET}
AUTH_URL: ${AUTH_URL}
AUTH_TRUST_HOST: 'true'
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER:-}
depends_on:
database:
condition: service_healthy
networks:
- wishlist-net
- traefik-net
labels:
- traefik.enable=true
- traefik.docker.network=traefik-net
# HTTPS router
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
- traefik.http.routers.wishlist.entrypoints=websecure
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
# Forward headers for Auth.js
- traefik.http.routers.wishlist.middlewares=wishlist-headers
- traefik.http.middlewares.wishlist-headers.headers.customRequestHeaders.X-Forwarded-Proto=https
- traefik.http.middlewares.wishlist-headers.headers.customRequestHeaders.X-Forwarded-Host=wish.rasmusq.com
- traefik.http.services.wishlist.loadbalancer.server.port=3000
networks:
wishlist-net:
name: wishlist-net
traefik-net:
external: true
+41
View File
@@ -0,0 +1,41 @@
services:
db:
image: postgres:16-alpine
container_name: wishlist-db
environment:
POSTGRES_USER: wishlistuser
POSTGRES_PASSWORD: wishlistpassword
POSTGRES_DB: wishlist
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wishlistuser -d wishlist"]
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: wishlist-app
environment:
DATABASE_URL: postgresql://wishlistuser:wishlistpassword@db:5432/wishlist
NODE_ENV: production
PORT: 3000
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
AUTH_URL: ${AUTH_URL:-http://localhost:3000}
AUTH_TRUST_HOST: true
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
ports:
- "3000:3000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
volumes:
postgres_data:
+1 -1
View File
@@ -1,7 +1,7 @@
import type { Config } from 'drizzle-kit'; import type { Config } from 'drizzle-kit';
export default { export default {
schema: './src/lib/db/schema.ts', schema: './src/lib/server/schema.ts',
out: './drizzle', out: './drizzle',
dialect: 'postgresql', dialect: 'postgresql',
dbCredentials: { dbCredentials: {
+9 -9
View File
@@ -1,12 +1,12 @@
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}) => ({
@@ -15,14 +15,14 @@ export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
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}) => ({
@@ -33,26 +33,26 @@ export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
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]
}) }),
})); }));
+82 -126
View File
@@ -1,94 +1,74 @@
import { import { pgTable, foreignKey, text, numeric, boolean, timestamp, unique, primaryKey } from "drizzle-orm/pg-core"
pgTable, import { sql } from "drizzle-orm"
foreignKey,
text,
numeric,
boolean,
timestamp,
unique,
primaryKey
} from 'drizzle-orm/pg-core';
export const items = pgTable(
'items',
{ export const items = pgTable("items", {
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
wishlistId: text('wishlist_id').notNull(), wishlistId: text("wishlist_id").notNull(),
title: text().notNull(), title: text().notNull(),
description: text(), description: text(),
link: text(), link: text(),
imageUrl: text('image_url'), imageUrl: text("image_url"),
price: numeric({ precision: 10, scale: 2 }), price: numeric({ precision: 10, scale: 2 }),
currency: text().default('DKK'), currency: text().default('DKK'),
color: text(), color: text(),
order: numeric().default('0').notNull(), order: numeric().default('0').notNull(),
isReserved: boolean('is_reserved').default(false).notNull(), isReserved: boolean("is_reserved").default(false).notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull() updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
}, }, (table) => [
(table) => [
foreignKey({ foreignKey({
columns: [table.wishlistId], columns: [table.wishlistId],
foreignColumns: [wishlists.id], foreignColumns: [wishlists.id],
name: 'items_wishlist_id_wishlists_id_fk' name: "items_wishlist_id_wishlists_id_fk"
}).onDelete('cascade') }).onDelete("cascade"),
] ]);
);
export const wishlists = pgTable( export const wishlists = pgTable("wishlists", {
'wishlists',
{
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
userId: text('user_id'), userId: text("user_id"),
title: text().notNull(), title: text().notNull(),
description: text(), description: text(),
ownerToken: text('owner_token').notNull(), ownerToken: text("owner_token").notNull(),
publicToken: text('public_token').notNull(), publicToken: text("public_token").notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(), isFavorite: boolean("is_favorite").default(false).notNull(),
color: text(), color: text(),
endDate: timestamp('end_date', { mode: 'string' }), endDate: timestamp("end_date", { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
theme: text().default('none') theme: text().default('none'),
}, }, (table) => [
(table) => [
foreignKey({ foreignKey({
columns: [table.userId], columns: [table.userId],
foreignColumns: [user.id], foreignColumns: [user.id],
name: 'wishlists_user_id_user_id_fk' name: "wishlists_user_id_user_id_fk"
}).onDelete('set null'), }).onDelete("set null"),
unique('wishlists_owner_token_unique').on(table.ownerToken), unique("wishlists_owner_token_unique").on(table.ownerToken),
unique('wishlists_public_token_unique').on(table.publicToken) unique("wishlists_public_token_unique").on(table.publicToken),
] ]);
);
export const savedWishlists = pgTable( export const savedWishlists = pgTable("saved_wishlists", {
'saved_wishlists',
{
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
userId: text('user_id').notNull(), userId: text("user_id").notNull(),
wishlistId: text('wishlist_id').notNull(), wishlistId: text("wishlist_id").notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(), isFavorite: boolean("is_favorite").default(false).notNull(),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
ownerToken: text('owner_token') ownerToken: text("owner_token"),
}, }, (table) => [
(table) => [
foreignKey({ foreignKey({
columns: [table.userId], columns: [table.userId],
foreignColumns: [user.id], foreignColumns: [user.id],
name: 'saved_wishlists_user_id_user_id_fk' name: "saved_wishlists_user_id_user_id_fk"
}).onDelete('cascade'), }).onDelete("cascade"),
foreignKey({ foreignKey({
columns: [table.wishlistId], columns: [table.wishlistId],
foreignColumns: [wishlists.id], foreignColumns: [wishlists.id],
name: 'saved_wishlists_wishlist_id_wishlists_id_fk' name: "saved_wishlists_wishlist_id_wishlists_id_fk"
}).onDelete('cascade') }).onDelete("cascade"),
] ]);
);
export const user = pgTable( export const user = pgTable("user", {
'user',
{
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
name: text(), name: text(),
email: text(), email: text(),
@@ -96,90 +76,66 @@ export const user = pgTable(
image: text(), image: text(),
password: text(), password: text(),
username: text(), username: text(),
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: 'string' }), lastLogin: timestamp("last_login", { mode: 'string' }),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull() updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
}, }, (table) => [
(table) => [ unique("user_email_unique").on(table.email),
unique('user_email_unique').on(table.email), unique("user_username_unique").on(table.username),
unique('user_username_unique').on(table.username) ]);
]
);
export const reservations = pgTable( export const reservations = pgTable("reservations", {
'reservations',
{
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
itemId: text('item_id').notNull(), itemId: text("item_id").notNull(),
reserverName: text('reserver_name'), reserverName: text("reserver_name"),
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull() createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
}, }, (table) => [
(table) => [
foreignKey({ foreignKey({
columns: [table.itemId], columns: [table.itemId],
foreignColumns: [items.id], foreignColumns: [items.id],
name: 'reservations_item_id_items_id_fk' name: "reservations_item_id_items_id_fk"
}).onDelete('cascade') }).onDelete("cascade"),
] ]);
);
export const session = pgTable( export const session = pgTable("session", {
'session',
{
sessionToken: text().primaryKey().notNull(), sessionToken: text().primaryKey().notNull(),
userId: text().notNull(), userId: text().notNull(),
expires: timestamp({ mode: 'string' }).notNull() expires: timestamp({ mode: 'string' }).notNull(),
}, }, (table) => [
(table) => [
foreignKey({ foreignKey({
columns: [table.userId], columns: [table.userId],
foreignColumns: [user.id], foreignColumns: [user.id],
name: 'session_userId_user_id_fk' name: "session_userId_user_id_fk"
}).onDelete('cascade') }).onDelete("cascade"),
] ]);
);
export const verificationToken = pgTable( export const verificationToken = pgTable("verificationToken", {
'verificationToken',
{
identifier: text().notNull(), identifier: text().notNull(),
token: text().notNull(), token: text().notNull(),
expires: timestamp({ mode: 'string' }).notNull() expires: timestamp({ mode: 'string' }).notNull(),
}, }, (table) => [
(table) => [ primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
primaryKey({ ]);
columns: [table.identifier, table.token],
name: 'verificationToken_identifier_token_pk'
})
]
);
export const account = pgTable( export const account = pgTable("account", {
'account',
{
userId: text().notNull(), userId: text().notNull(),
type: text().notNull(), type: text().notNull(),
provider: text().notNull(), provider: text().notNull(),
providerAccountId: text().notNull(), providerAccountId: text().notNull(),
refreshToken: text('refresh_token'), refreshToken: text("refresh_token"),
accessToken: text('access_token'), accessToken: text("access_token"),
expiresAt: numeric('expires_at'), expiresAt: numeric("expires_at"),
tokenType: text('token_type'), tokenType: text("token_type"),
scope: text(), scope: text(),
idToken: text('id_token'), idToken: text("id_token"),
sessionState: text('session_state') sessionState: text("session_state"),
}, }, (table) => [
(table) => [
foreignKey({ foreignKey({
columns: [table.userId], columns: [table.userId],
foreignColumns: [user.id], foreignColumns: [user.id],
name: 'account_userId_user_id_fk' name: "account_userId_user_id_fk"
}).onDelete('cascade'), }).onDelete("cascade"),
primaryKey({ primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
columns: [table.provider, table.providerAccountId], ]);
name: 'account_provider_providerAccountId_pk'
})
]
);
-47
View File
@@ -1,47 +0,0 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import svelteParser from 'svelte-eslint-parser';
/** @type {import('eslint').Linter.Config[]} */
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
},
rules: {
// Disable overly strict Svelte navigation rules
'svelte/no-navigation-without-resolve': 'off',
'svelte/no-navigation-without-base': 'off'
}
},
// Configuration for .svelte files
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
// Configuration for .svelte.ts files (Svelte runes in TypeScript)
{
files: ['**/*.svelte.ts'],
languageOptions: {
parser: svelteParser,
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
];
+2 -11
View File
@@ -14,12 +14,9 @@
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate", "db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push", "db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio"
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
}, },
"devDependencies": { "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",
@@ -28,20 +25,13 @@
"@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",
"svelte-eslint-parser": "^1.6.0",
"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"
}, },
"dependencies": { "dependencies": {
@@ -54,6 +44,7 @@
"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",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 KiB

+2 -2
View File
@@ -1,6 +1,6 @@
@import 'tailwindcss'; @import "tailwindcss";
@import 'tw-animate-css'; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
+1 -2
View File
@@ -6,8 +6,7 @@
<script> <script>
(function() { (function() {
const theme = localStorage.getItem('theme') || 'system'; const theme = localStorage.getItem('theme') || 'system';
const isDark = const isDark = theme === 'dark' ||
theme === 'dark' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) { if (isDark) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
+10 -13
View File
@@ -4,25 +4,17 @@ import Credentials from '@auth/core/providers/credentials';
import Google from '@auth/core/providers/google'; import Google from '@auth/core/providers/google';
import type { OAuthConfig } from '@auth/core/providers'; import type { OAuthConfig } from '@auth/core/providers';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { users } from '$lib/db/schema'; import { users } from '$lib/server/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import type { SvelteKitAuthConfig } from '@auth/sveltekit'; import type { SvelteKitAuthConfig } from '@auth/sveltekit';
interface AuthentikProfile {
sub: string;
email: string;
name?: string;
preferred_username?: string;
picture?: string;
}
function Authentik(config: { function Authentik(config: {
clientId: string; clientId: string;
clientSecret: string; clientSecret: string;
issuer: string; issuer: string;
}): OAuthConfig<AuthentikProfile> { }): OAuthConfig<any> {
return { return {
id: 'authentik', id: 'authentik',
name: 'Authentik', name: 'Authentik',
@@ -89,7 +81,10 @@ const authConfig: SvelteKitAuthConfig = {
return null; return null;
} }
const isValidPassword = await bcrypt.compare(credentials.password as string, user.password); const isValidPassword = await bcrypt.compare(
credentials.password as string,
user.password
);
if (!isValidPassword) { if (!isValidPassword) {
return null; return null;
@@ -110,7 +105,9 @@ const authConfig: SvelteKitAuthConfig = {
callbacks: { callbacks: {
async signIn({ user }) { async signIn({ user }) {
if (user?.id) { if (user?.id) {
await db.update(users).set({ lastLogin: new Date() }).where(eq(users.id, user.id)); await db.update(users)
.set({ lastLogin: new Date() })
.where(eq(users.id, user.id));
} }
return true; return true;
}, },
@@ -128,7 +125,7 @@ const authConfig: SvelteKitAuthConfig = {
} }
}, },
secret: env.AUTH_SECRET, secret: env.AUTH_SECRET,
trustHost: env.AUTH_TRUST_HOST === 'true' trustHost: true
}; };
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig); export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);
@@ -1,14 +1,9 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte'; import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
import { import { getLocalWishlists, forgetLocalWishlist, toggleLocalFavorite, type LocalWishlist } from '$lib/utils/localWishlists';
getLocalWishlists,
forgetLocalWishlist,
toggleLocalFavorite,
type LocalWishlist
} from '$lib/utils/localWishlists';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { Star } from '@lucide/svelte'; import { Star } from 'lucide-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let { let {
@@ -24,11 +19,13 @@
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
let localWishlists = $state<LocalWishlist[]>([]); let localWishlists = $state<LocalWishlist[]>([]);
let enrichedWishlists = $state<Array<Record<string, unknown>>>([]); let enrichedWishlists = $state<any[]>([]);
// Load local wishlists on mount and fetch their data from server
onMount(async () => { onMount(async () => {
localWishlists = getLocalWishlists(); localWishlists = getLocalWishlists();
// Fetch full wishlist data for each local wishlist
const promises = localWishlists.map(async (local) => { const promises = localWishlists.map(async (local) => {
try { try {
const response = await fetch(`/api/wishlist/${local.ownerToken}`); const response = await fetch(`/api/wishlist/${local.ownerToken}`);
@@ -42,6 +39,7 @@
} catch (error) { } catch (error) {
console.error('Failed to fetch wishlist data:', error); console.error('Failed to fetch wishlist data:', error);
} }
// Fallback to local data if fetch fails
return { return {
id: local.ownerToken, id: local.ownerToken,
title: local.title, title: local.title,
@@ -106,37 +104,35 @@
// Description depends on authentication status // Description depends on authentication status
const sectionDescription = $derived(() => { const sectionDescription = $derived(() => {
if (isAuthenticated) { if (isAuthenticated) {
return ( return t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet.";
t.dashboard.localWishlistsAuthDescription ||
"Wishlists stored in your browser that haven't been claimed yet."
);
} }
return ( return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently.";
t.dashboard.localWishlistsDescription ||
'Wishlists stored in your browser. Sign in to save them permanently.'
);
}); });
</script> </script>
<WishlistSection <WishlistSection
title={t.dashboard.localWishlists || 'Local Wishlists'} title={t.dashboard.localWishlists || "Local Wishlists"}
description={sectionDescription()} description={sectionDescription()}
items={transformedWishlists()} items={transformedWishlists()}
emptyMessage={t.dashboard.emptyLocalWishlists || 'No local wishlists yet'} emptyMessage={t.dashboard.emptyLocalWishlists || "No local wishlists yet"}
emptyActionLabel={t.dashboard.createLocalWishlist || 'Create local wishlist'} emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
emptyActionHref="/" emptyActionHref="/"
showCreateButton={true} showCreateButton={true}
{fallbackColor} fallbackColor={fallbackColor}
{fallbackTheme} fallbackTheme={fallbackTheme}
> >
{#snippet actions(wishlist: Record<string, unknown>, unlocked: boolean)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<Button size="sm" variant="outline" onclick={() => handleToggleFavorite(wishlist.ownerToken as string)}> <Button
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> size="sm"
variant="outline"
onclick={() => handleToggleFavorite(wishlist.ownerToken)}
>
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
</Button> </Button>
<Button <Button
size="sm" size="sm"
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken as string}/edit`)} onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
> >
{t.dashboard.manage} {t.dashboard.manage}
</Button> </Button>
@@ -145,15 +141,19 @@
variant="outline" variant="outline"
onclick={() => { onclick={() => {
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${window.location.origin}/wishlist/${wishlist.publicToken as string}` `${window.location.origin}/wishlist/${wishlist.publicToken}`
); );
}} }}
> >
{t.dashboard.copyLink} {t.dashboard.copyLink}
</Button> </Button>
{#if unlocked} {#if unlocked}
<Button size="sm" variant="destructive" onclick={() => handleForget(wishlist.ownerToken as string)}> <Button
{t.dashboard.forget || 'Forget'} size="sm"
variant="destructive"
onclick={() => handleForget(wishlist.ownerToken)}
>
{t.dashboard.forget || "Forget"}
</Button> </Button>
{/if} {/if}
</div> </div>
@@ -1,11 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import { Button } from '$lib/components/ui/button';
Card, import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { getCardStyle } from '$lib/utils/colors'; import { getCardStyle } from '$lib/utils/colors';
import ThemeCard from '$lib/components/themes/ThemeCard.svelte'; import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
@@ -1,11 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card, import { Button } from '$lib/components/ui/button';
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import EmptyState from '$lib/components/layout/EmptyState.svelte'; import EmptyState from '$lib/components/layout/EmptyState.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { flip } from 'svelte/animate'; import { flip } from 'svelte/animate';
@@ -28,7 +23,7 @@
}: { }: {
title: string; title: string;
description: string; description: string;
items: Array<Record<string, unknown>>; items: any[];
emptyMessage: string; emptyMessage: string;
emptyDescription?: string; emptyDescription?: string;
emptyActionLabel?: string; emptyActionLabel?: string;
@@ -37,7 +32,7 @@
fallbackTheme?: string | null; fallbackTheme?: string | null;
headerAction?: Snippet; headerAction?: Snippet;
searchBar?: Snippet; searchBar?: Snippet;
children: Snippet<[Record<string, unknown>]>; children: Snippet<[any]>;
} = $props(); } = $props();
const cardStyle = $derived(getCardStyle(fallbackColor, null)); const cardStyle = $derived(getCardStyle(fallbackColor, null));
@@ -2,22 +2,14 @@
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 { 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';
interface WishlistItem { type WishlistItem = any; // You can make this more specific based on your types
id?: string;
wishlist?: Record<string, unknown>;
title?: string;
description?: string;
isFavorite?: boolean;
endDate?: string | Date;
createdAt?: string | Date;
items?: Array<{ title: string }>;
user?: { name?: string; username?: string };
}
let { let {
title, title,
@@ -56,7 +48,7 @@
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();
@@ -97,20 +89,16 @@
function formatEndDate(date: Date | string | null): string | null { function formatEndDate(date: Date | string | null): string | null {
if (!date) return null; if (!date) return null;
const d = new Date(date); const d = new Date(date);
return d.toLocaleDateString(languageStore.t.date.format.short, { return d.toLocaleDateString(languageStore.t.date.format.short, { year: 'numeric', month: 'short', day: 'numeric' });
year: 'numeric',
month: 'short',
day: 'numeric'
});
} }
function getWishlistDescription(item: WishlistItem): string | null { function getWishlistDescription(item: any): string | null {
const wishlist = item.wishlist || item; const wishlist = item.wishlist || item;
if (!wishlist) return null; if (!wishlist) return null;
const lines: string[] = []; const lines: string[] = [];
const topItems = wishlist.items?.slice(0, 3).map((i: { title: string }) => i.title) || []; const topItems = wishlist.items?.slice(0, 3).map((i: any) => i.title) || [];
if (topItems.length > 0) { if (topItems.length > 0) {
lines.push(topItems.join(', ')); lines.push(topItems.join(', '));
} }
@@ -168,8 +156,8 @@
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>
@@ -6,6 +6,7 @@
import ColorPicker from '$lib/components/ui/ColorPicker.svelte'; import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
import { signOut } from '@auth/sveltekit/client'; import { signOut } from '@auth/sveltekit/client';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
import { enhance } from '$app/forms';
let { let {
userName, userName,
@@ -43,7 +44,11 @@
} }
} }
let localColor = $derived(dashboardColor); let localColor = $state(dashboardColor);
$effect(() => {
localColor = dashboardColor;
});
async function handleColorChange() { async function handleColorChange() {
if (onColorUpdate) { if (onColorUpdate) {
@@ -68,28 +73,20 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1> <h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
{#if isAuthenticated} {#if isAuthenticated}
<p class="text-muted-foreground truncate"> <p class="text-muted-foreground truncate">{t.dashboard.welcomeBack}, {userName || userEmail}</p>
{t.dashboard.welcomeBack}, {userName || userEmail}
</p>
{:else} {:else}
<p class="text-muted-foreground"> <p class="text-muted-foreground">{t.dashboard.anonymousDashboard || "Your local wishlists"}</p>
{t.dashboard.anonymousDashboard || 'Your local wishlists'}
</p>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0"> <div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" /> <ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} /> <ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} />
<LanguageToggle color={localColor} /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
{#if isAuthenticated} {#if isAuthenticated}
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })} <Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>
>{t.auth.signOut}</Button
>
{:else} {:else}
<Button variant="outline" onclick={() => (window.location.href = '/signin')} <Button variant="outline" onclick={() => (window.location.href = '/signin')}>{t.auth.signIn}</Button>
>{t.auth.signIn}</Button
>
{/if} {/if}
</div> </div>
</div> </div>
+7 -17
View File
@@ -2,15 +2,15 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ThemeToggle } from '$lib/components/ui/theme-toggle'; import { ThemeToggle } from '$lib/components/ui/theme-toggle';
import { LanguageToggle } from '$lib/components/ui/language-toggle'; import { LanguageToggle } from '$lib/components/ui/language-toggle';
import { LayoutDashboard } from '@lucide/svelte'; import { LayoutDashboard } from 'lucide-svelte';
import { languageStore } from '$lib/stores/language.svelte'; import { languageStore } from '$lib/stores/language.svelte';
let { let {
isAuthenticated = false, isAuthenticated = false,
color = null showDashboardLink = false
}: { }: {
isAuthenticated?: boolean; isAuthenticated?: boolean;
color?: string | null; showDashboardLink?: boolean;
} = $props(); } = $props();
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
@@ -18,27 +18,17 @@
<nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full"> <nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full">
{#if isAuthenticated} {#if isAuthenticated}
<Button <Button variant="outline" size="sm" onclick={() => (window.location.href = '/dashboard')} class="px-2 sm:px-3">
variant="outline"
size="sm"
onclick={() => (window.location.href = '/dashboard')}
class="px-2 sm:px-3"
>
<LayoutDashboard class="w-4 h-4" /> <LayoutDashboard class="w-4 h-4" />
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span> <span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
</Button> </Button>
{:else} {:else}
<Button <Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')} class="px-2 sm:px-3">
variant="outline"
size="sm"
onclick={() => (window.location.href = '/signin')}
class="px-2 sm:px-3"
>
{t.auth.signIn} {t.auth.signIn}
</Button> </Button>
{/if} {/if}
<div class="ml-auto flex items-center gap-1 sm:gap-2"> <div class="ml-auto flex items-center gap-1 sm:gap-2">
<LanguageToggle {color} /> <LanguageToggle />
<ThemeToggle size="sm" {color} /> <ThemeToggle />
</div> </div>
</nav> </nav>
@@ -7,11 +7,13 @@
let { let {
themeName, themeName,
showTop = true, showTop = true,
showBottom = true showBottom = true,
color
}: { }: {
themeName?: string | null; themeName?: string | null;
showTop?: boolean; showTop?: boolean;
showBottom?: boolean; showBottom?: boolean;
color?: string;
} = $props(); } = $props();
const theme = $derived(getTheme(themeName)); const theme = $derived(getTheme(themeName));
@@ -5,9 +5,11 @@
let { let {
themeName, themeName,
color,
showPattern = true showPattern = true
}: { }: {
themeName?: string | null; themeName?: string | null;
color?: string | null;
showPattern?: boolean; showPattern?: boolean;
} = $props(); } = $props();
@@ -19,6 +19,7 @@
class="fixed top-0 right-0 left-0 pointer-events-none z-0" class="fixed top-0 right-0 left-0 pointer-events-none z-0"
style=" style="
mask-image: url({patternPath}); mask-image: url({patternPath});
-webkit-mask-image: url({patternPath});
mask-size: cover; mask-size: cover;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: right top; mask-position: right top;
+10 -9
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { X, Pencil } from '@lucide/svelte'; import { X, Pencil } from 'lucide-svelte';
import IconButton from './IconButton.svelte';
let { let {
color = $bindable(null), color = $bindable(null),
@@ -40,18 +39,20 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if color} {#if color}
<IconButton onclick={clearColor} {color} {size} aria-label="Clear color" rounded="md"> <button
type="button"
onclick={clearColor}
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
aria-label="Clear color"
>
<X class={iconSize} /> <X class={iconSize} />
</IconButton> </button>
{/if} {/if}
<label <label
class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden" class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
style={color ? `background-color: ${color};` : ''} style={color ? `background-color: ${color};` : ''}
> >
<Pencil <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));' : ''} />
class="{iconSize} relative z-10 pointer-events-none"
style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''}
/>
<input <input
type="color" type="color"
value={color || '#ffffff'} value={color || '#ffffff'}
-132
View File
@@ -1,132 +0,0 @@
<script lang="ts">
import { scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import type { Snippet } from 'svelte';
import IconButton from './IconButton.svelte';
let {
items,
selectedValue,
onSelect,
color,
showCheckmark = true,
icon,
ariaLabel
}: {
items: Array<{ value: string; label: string }>;
selectedValue: string;
onSelect: (value: string) => void;
color?: string | null;
showCheckmark?: boolean;
icon: Snippet;
ariaLabel: string;
} = $props();
let showMenu = $state(false);
const menuClasses = $derived(
color
? 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md'
: 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-950/90'
);
const menuStyle = $derived(
color
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
: ''
);
function getItemStyle(itemValue: string): string {
if (!color) return '';
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
}
function toggleMenu() {
showMenu = !showMenu;
}
function handleSelect(value: string) {
onSelect(value);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-menu')) {
showMenu = false;
}
}
function handleMouseEnter(e: MouseEvent) {
if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
}
}
function handleMouseLeave(e: MouseEvent, itemValue: string) {
if (color) {
(e.currentTarget as HTMLElement).style.backgroundColor =
selectedValue === itemValue ? `${color}20` : 'transparent';
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script>
<div class="relative dropdown-menu">
<IconButton
size="sm"
rounded="md"
onclick={toggleMenu}
aria-label={ariaLabel}
class={color ? 'hover-themed' : ''}
style={color ? `--hover-bg: ${color}20;` : ''}
>
{@render icon()}
</IconButton>
{#if showMenu}
<div
class={menuClasses}
style={menuStyle}
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
>
<div class="py-1">
{#each items as item (item.value)}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm transition-colors"
class:hover:bg-slate-100={!color}
class:dark:hover:bg-slate-900={!color}
class:font-bold={selectedValue === item.value}
class:bg-slate-100={selectedValue === item.value && !color}
class:dark:bg-slate-900={selectedValue === item.value && !color}
class:flex={showCheckmark}
class:items-center={showCheckmark}
class:justify-between={showCheckmark}
style={getItemStyle(item.value)}
onmouseenter={handleMouseEnter}
onmouseleave={(e) => handleMouseLeave(e, item.value)}
onclick={() => handleSelect(item.value)}
>
<span>{item.label}</span>
{#if showCheckmark && selectedValue === item.value}
<span class="ml-2"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
<style>
:global(.hover-themed:hover) {
background-color: var(--hover-bg) !important;
}
</style>
-53
View File
@@ -1,53 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
interface Props extends HTMLButtonAttributes {
color?: string | null;
rounded?: 'full' | 'md' | 'lg';
size?: 'sm' | 'md' | 'lg';
children: Snippet;
}
let {
color = null,
rounded = 'full',
size = 'md',
class: className = '',
children,
...restProps
}: Props = $props();
const sizeClasses = {
sm: 'w-8 h-8',
md: 'w-10 h-10',
lg: 'w-12 h-12'
};
const roundedClasses = {
full: 'rounded-full',
md: 'rounded-md',
lg: 'rounded-lg'
};
const baseClasses =
'flex items-center justify-center border border-input transition-colors backdrop-blur';
const sizeClass = sizeClasses[size];
const roundedClass = roundedClasses[rounded];
</script>
<button
type="button"
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
class:hover:bg-accent={!color}
style={color ? `--hover-bg: ${color}20;` : ''}
{...restProps}
>
{@render children()}
</button>
<style>
button[style*='--hover-bg']:hover {
background-color: var(--hover-bg);
}
</style>
+5 -1
View File
@@ -11,4 +11,8 @@
} = $props(); } = $props();
</script> </script>
<Input type="search" {placeholder} bind:value /> <Input
type="search"
{placeholder}
bind:value
/>
+5 -2
View File
@@ -1,6 +1,6 @@
<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 {
@@ -16,7 +16,10 @@
} }
</script> </script>
<Button onclick={handleClick} variant={unlocked ? 'default' : 'outline'}> <Button
onclick={handleClick}
variant={unlocked ? "default" : "outline"}
>
{#if unlocked} {#if unlocked}
<Lock class="mr-2 h-4 w-4" /> <Lock class="mr-2 h-4 w-4" />
{t.wishlist.lockDeletion} {t.wishlist.lockDeletion}
+4 -2
View File
@@ -7,12 +7,14 @@
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0', base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', default:
'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:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline' link: 'text-primary underline-offset-4 hover:underline'
}, },
@@ -3,7 +3,7 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
@@ -3,7 +3,7 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLParagraphElement> & { type Props = HTMLAttributes<HTMLParagraphElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
@@ -3,7 +3,7 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
+1 -1
View File
@@ -3,7 +3,7 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLHeadingElement> & { type Props = HTMLAttributes<HTMLHeadingElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
+5 -2
View File
@@ -3,12 +3,15 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
</script> </script>
<div class={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...restProps}> <div
class={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
{...restProps}
>
{@render children?.()} {@render children?.()}
</div> </div>
+1 -1
View File
@@ -10,7 +10,7 @@
</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
+1 -1
View File
@@ -3,7 +3,7 @@
import type { HTMLLabelAttributes } from 'svelte/elements'; import type { HTMLLabelAttributes } from 'svelte/elements';
type Props = HTMLLabelAttributes & { type Props = HTMLLabelAttributes & {
children?: import('svelte').Snippet; children?: any;
}; };
let { class: className, children, ...restProps }: Props = $props(); let { class: className, children, ...restProps }: Props = $props();
@@ -1,32 +1,58 @@
<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 { Button } from '$lib/components/ui/button';
import { Languages } from '@lucide/svelte'; import { Languages } from 'lucide-svelte';
let { color }: { color?: string | null } = $props(); let showMenu = $state(false);
const languageItems = $derived( function toggleMenu() {
languages.map((lang) => ({ showMenu = !showMenu;
value: lang.code,
label: lang.name
}))
);
function setLanguage(code: string) {
languageStore.setLanguage(code as 'en' | 'da');
} }
function setLanguage(code: 'en' | 'da') {
languageStore.setLanguage(code);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.language-toggle-menu')) {
showMenu = false;
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script> </script>
<Dropdown <div class="relative language-toggle-menu">
items={languageItems} <Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Toggle language">
selectedValue={languageStore.current}
onSelect={setLanguage}
{color}
showCheckmark={false}
ariaLabel="Toggle language"
>
{#snippet icon()}
<Languages class="h-[1.2rem] w-[1.2rem]" /> <Languages class="h-[1.2rem] w-[1.2rem]" />
{/snippet} </Button>
</Dropdown>
{#if showMenu}
<div
class="absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 shadow-lg z-50"
>
<div class="py-1">
{#each languages as lang}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
class:font-bold={languageStore.current === lang.code}
class:bg-slate-100={languageStore.current === lang.code}
class:dark:bg-slate-900={languageStore.current === lang.code}
onclick={() => setLanguage(lang.code)}
>
{lang.name}
</button>
{/each}
</div>
</div>
{/if}
</div>
+55 -22
View File
@@ -1,35 +1,68 @@
<script lang="ts"> <script lang="ts">
import Dropdown from '$lib/components/ui/Dropdown.svelte'; import { Button } from '$lib/components/ui/button';
import { Palette } from '@lucide/svelte'; import { Palette } from 'lucide-svelte';
import { AVAILABLE_THEMES } from '$lib/utils/themes'; import { AVAILABLE_THEMES } from '$lib/utils/themes';
let { let {
value = 'none', value = 'none',
onValueChange, onValueChange
color
}: { }: {
value?: string; value?: string;
onValueChange: (theme: string) => void; onValueChange: (theme: string) => void;
color?: string | null;
} = $props(); } = $props();
const themeItems = $derived( let showMenu = $state(false);
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
value: key, function toggleMenu() {
label: theme.name showMenu = !showMenu;
})) }
);
function handleSelect(themeName: string) {
onValueChange(themeName);
showMenu = false;
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.theme-picker-menu')) {
showMenu = false;
}
}
$effect(() => {
if (showMenu) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
});
</script> </script>
<Dropdown <div class="relative theme-picker-menu">
items={themeItems} <Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Select theme pattern">
selectedValue={value}
onSelect={onValueChange}
{color}
showCheckmark={true}
ariaLabel="Select theme pattern"
>
{#snippet icon()}
<Palette class="h-[1.2rem] w-[1.2rem]" /> <Palette class="h-[1.2rem] w-[1.2rem]" />
{/snippet} </Button>
</Dropdown>
{#if showMenu}
<div
class="absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 shadow-lg z-50"
>
<div class="py-1">
{#each Object.entries(AVAILABLE_THEMES) as [key, theme]}
<button
type="button"
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors flex items-center justify-between"
class:font-bold={value === key}
class:bg-slate-100={value === key}
class:dark:bg-slate-900={value === key}
onclick={() => handleSelect(key)}
>
<span>{theme.name}</span>
{#if value === key}
<span class="ml-2"></span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
@@ -1,22 +1,14 @@
<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 { Button } from '$lib/components/ui/button';
import IconButton from '../IconButton.svelte'; import { Sun, Moon, Monitor } from 'lucide-svelte';
let {
color = $bindable(null),
size = 'sm'
}: {
color: string | null;
size?: 'sm' | 'md' | 'lg';
} = $props();
function toggle() { function toggle() {
themeStore.toggle(); themeStore.toggle();
} }
</script> </script>
<IconButton onclick={toggle} {size} {color} rounded="md"> <Button onclick={toggle} variant="ghost" size="icon" class="rounded-full">
{#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>
@@ -27,4 +19,4 @@
<Monitor size={20} /> <Monitor size={20} />
<span class="sr-only">System mode (click for light)</span> <span class="sr-only">System mode (click for light)</span>
{/if} {/if}
</IconButton> </Button>
@@ -114,11 +114,7 @@
bind:value={imageUrl} bind:value={imageUrl}
/> />
<ImageSelector <ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
images={scrapedImages}
bind:selectedImage={imageUrl}
isLoading={isLoadingImages}
/>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
@@ -133,7 +129,7 @@
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 (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>
@@ -142,7 +138,7 @@
<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 /> <ColorPicker bind:color={color} />
</div> </div>
<input type="hidden" name="color" value={color || ''} /> <input type="hidden" name="color" value={color || ''} />
</div> </div>
@@ -25,7 +25,11 @@
{#if isAuthenticated} {#if isAuthenticated}
<div class="mb-6"> <div class="mb-6">
{#if isOwner} {#if isOwner}
<Button disabled variant="outline" class="w-full md:w-auto opacity-60 cursor-not-allowed"> <Button
disabled
variant="outline"
class="w-full md:w-auto opacity-60 cursor-not-allowed"
>
{t.wishlist.youOwnThis} {t.wishlist.youOwnThis}
</Button> </Button>
<p class="text-sm text-muted-foreground mt-2"> <p class="text-sm text-muted-foreground mt-2">
@@ -34,15 +38,19 @@
{:else} {:else}
<form <form
method="POST" method="POST"
action={hasClaimed ? '?/unclaimWishlist' : '?/claimWishlist'} action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"}
use:enhance={() => { use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}
> >
<Button type="submit" variant={hasClaimed ? 'outline' : 'default'} class="w-full md:w-auto"> <Button
{hasClaimed ? 'Unclaim Wishlist' : 'Claim Wishlist'} type="submit"
variant={hasClaimed ? "outline" : "default"}
class="w-full md:w-auto"
>
{hasClaimed ? "Unclaim Wishlist" : "Claim Wishlist"}
</Button> </Button>
</form> </form>
<p class="text-sm text-muted-foreground mt-2"> <p class="text-sm text-muted-foreground mt-2">
@@ -52,9 +60,7 @@
Claim this wishlist to add it to your dashboard for easy access. Claim this wishlist to add it to your dashboard for easy access.
{#if isLocal} {#if isLocal}
<br /> <br />
<span class="text-xs" <span class="text-xs">It will remain in your local wishlists and also appear in your claimed wishlists.</span>
>It will remain in your local wishlists and also appear in your claimed wishlists.</span
>
{/if} {/if}
{/if} {/if}
</p> </p>
@@ -27,13 +27,17 @@
return; return;
} }
return async ({ result }) => { return async ({ result }) => {
if (result.type === 'success') { if (result.type === "success") {
window.location.href = '/dashboard'; window.location.href = "/dashboard";
} }
}; };
}} }}
> >
<Button type="submit" variant="destructive" class="w-full md:w-auto"> <Button
type="submit"
variant="destructive"
class="w-full md:w-auto"
>
{t.wishlist.deleteWishlist} {t.wishlist.deleteWishlist}
</Button> </Button>
</form> </form>
@@ -7,7 +7,7 @@
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/db/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';
@@ -24,17 +24,7 @@
wishlistTheme?: string | null; wishlistTheme?: string | null;
} }
let { let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange, wishlistColor = null, wishlistTheme = null }: Props = $props();
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));
@@ -98,13 +88,7 @@
<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 <Input id="title" name="title" required value={item.title} placeholder="e.g., Blue Headphones" />
id="title"
name="title"
required
value={item.title}
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">
@@ -140,23 +124,12 @@
bind:value={imageUrl} bind:value={imageUrl}
/> />
<ImageSelector <ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
images={scrapedImages}
bind:selectedImage={imageUrl}
isLoading={isLoadingImages}
/>
</div> </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 <Input id="price" name="price" type="number" step="0.01" value={item.price || ''} placeholder="0.00" />
id="price"
name="price"
type="number"
step="0.01"
value={item.price || ''}
placeholder="0.00"
/>
</div> </div>
<div class="space-y-2 md:col-span-2"> <div class="space-y-2 md:col-span-2">
@@ -166,7 +139,7 @@
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 (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>
@@ -175,7 +148,7 @@
<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 onchange={() => onColorChange?.(item.id, color || '')} /> <ColorPicker bind:color={color} onchange={() => onColorChange?.(item.id, color || '')} />
</div> </div>
<input type="hidden" name="color" value={color || ''} /> <input type="hidden" name="color" value={color || ''} />
</div> </div>
@@ -205,9 +178,7 @@
<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} <Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={onCancel}>{t.form.cancel}</Button>
>{t.form.cancel}</Button
>
{/if} {/if}
</div> </div>
</form> </form>
@@ -1,25 +1,27 @@
<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,
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>;
theme?: string | null; theme?: string | null;
wishlistColor?: string | null; wishlistColor?: string | null;
} = $props(); } = $props();
@@ -44,9 +46,21 @@
{t.wishlist.edit} {t.wishlist.edit}
</Button> </Button>
{#if rearranging} {#if rearranging}
<form method="POST" action="?/deleteItem" use:enhance> <form
<input type="hidden" name="itemId" value={item.id} /> method="POST"
<Button type="submit" variant="destructive" size="sm"> action="?/deleteItem"
use:enhance
>
<input
type="hidden"
name="itemId"
value={item.id}
/>
<Button
type="submit"
variant="destructive"
size="sm"
>
{t.form.delete} {t.form.delete}
</Button> </Button>
</form> </form>
@@ -58,9 +72,11 @@
</div> </div>
{:else} {:else}
<Card style={cardStyle} class="relative overflow-hidden"> <Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} /> <ThemeCard themeName={theme} color={wishlistColor} />
<CardContent class="p-12 relative z-10"> <CardContent class="p-12 relative z-10">
<EmptyState message={t.wishlist.noWishes + '. ' + t.wishlist.addFirstWish + '!'} /> <EmptyState
message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"}
/>
</CardContent> </CardContent>
</Card> </Card>
{/if} {/if}
@@ -18,7 +18,7 @@
<div class="mt-2"> <div class="mt-2">
<Label class="text-sm">Or select from scraped images:</Label> <Label class="text-sm">Or select from scraped images:</Label>
<div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2"> <div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2">
{#each images as imgUrl (imgUrl)} {#each images as imgUrl}
<button <button
type="button" type="button"
onclick={() => (selectedImage = imgUrl)} onclick={() => (selectedImage = imgUrl)}
@@ -39,20 +39,20 @@
{#if canCancel()} {#if canCancel()}
{#if showCancelConfirmation} {#if showCancelConfirmation}
<div class="flex flex-col gap-2 items-start"> <div class="flex flex-col gap-2 items-start">
<p class="text-sm text-muted-foreground">Cancel this reservation?</p> <p class="text-sm text-muted-foreground">
Cancel this reservation?
</p>
<div class="flex gap-2"> <div class="flex gap-2">
<form <form method="POST" action="?/unreserve" use:enhance={() => {
method="POST"
action="?/unreserve"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
showCancelConfirmation = false; showCancelConfirmation = false;
await update(); await update();
}; };
}} }}>
>
<input type="hidden" name="itemId" value={itemId} /> <input type="hidden" name="itemId" value={itemId} />
<Button type="submit" variant="destructive" size="sm">Yes, Cancel</Button> <Button type="submit" variant="destructive" size="sm">
Yes, Cancel
</Button>
</form> </form>
<Button <Button
type="button" type="button"
@@ -76,7 +76,9 @@
{:else} {:else}
<form method="POST" action="?/unreserve" use:enhance> <form method="POST" action="?/unreserve" use:enhance>
<input type="hidden" name="itemId" value={itemId} /> <input type="hidden" name="itemId" value={itemId} />
<Button type="submit" variant="outline" size="sm">Cancel Reservation</Button> <Button type="submit" variant="outline" size="sm">
Cancel Reservation
</Button>
</form> </form>
{/if} {/if}
{/if} {/if}
@@ -23,9 +23,7 @@
const publicLink = $derived( const publicLink = $derived(
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : '' typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
); );
const ownerLink = $derived( const ownerLink = $derived(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : '');
ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : ''
);
async function copyToClipboard(text: string, type: 'public' | 'owner') { async function copyToClipboard(text: string, type: 'public' | 'owner') {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -1,5 +1,5 @@
<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 {
@@ -16,7 +16,10 @@
</script> </script>
<div class="flex flex-col md:flex-row gap-4"> <div class="flex flex-col md:flex-row gap-4">
<Button onclick={onToggleAddForm} class="w-full md:w-auto"> <Button
onclick={onToggleAddForm}
class="w-full md:w-auto"
>
{showAddForm ? t.form.cancel : t.wishlist.addWish} {showAddForm ? t.form.cancel : t.wishlist.addWish}
</Button> </Button>
</div> </div>
@@ -1,13 +1,12 @@
<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 type { Wishlist } from "$lib/server/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';
@@ -32,11 +31,13 @@
let editingTitle = $state(false); let editingTitle = $state(false);
let editingDescription = $state(false); let editingDescription = $state(false);
let wishlistTitle = $state(wishlist.title); let wishlistTitle = $state(wishlist.title);
let wishlistDescription = $state(wishlist.description || ''); let wishlistDescription = $state(wishlist.description || "");
let wishlistColor = $state<string | null>(wishlist.color); let wishlistColor = $state<string | null>(wishlist.color);
let wishlistTheme = $state<string>(wishlist.theme || 'none'); let wishlistTheme = $state<string>(wishlist.theme || 'none');
let wishlistEndDate = $state<string | null>( let wishlistEndDate = $state<string | null>(
wishlist.endDate ? new Date(wishlist.endDate).toISOString().split('T')[0] : null wishlist.endDate
? new Date(wishlist.endDate).toISOString().split("T")[0]
: null,
); );
const cardStyle = $derived(getCardStyle(null, wishlistColor)); const cardStyle = $derived(getCardStyle(null, wishlistColor));
@@ -62,7 +63,7 @@
if (success) { if (success) {
editingDescription = false; editingDescription = false;
} else { } else {
wishlistDescription = wishlist.description || ''; wishlistDescription = wishlist.description || "";
editingDescription = false; editingDescription = false;
} }
} }
@@ -79,6 +80,7 @@
} }
</script> </script>
<!-- Title Header -->
<div class="flex items-center justify-between gap-4 mb-6"> <div class="flex items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-2 flex-1 min-w-0"> <div class="flex items-center gap-2 flex-1 min-w-0">
{#if editingTitle} {#if editingTitle}
@@ -86,9 +88,9 @@
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;
} }
@@ -98,7 +100,8 @@
{: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 <button
type="button"
onclick={() => { onclick={() => {
if (editingTitle) { if (editingTitle) {
saveTitle(); saveTitle();
@@ -106,17 +109,15 @@
editingTitle = true; editingTitle = true;
} }
}} }}
color={wishlistColor} class="shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
size="sm" aria-label={editingTitle ? "Save title" : "Edit title"}
class="shrink-0"
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> </button>
</div> </div>
<div class="flex items-center gap-2 shrink-0"> <div class="flex items-center gap-2 shrink-0">
<ThemePicker <ThemePicker
@@ -127,7 +128,6 @@
// Force reactivity by updating the wishlist object // Force reactivity by updating the wishlist object
wishlist.theme = theme; wishlist.theme = theme;
}} }}
color={wishlistColor}
/> />
<ColorPicker <ColorPicker
bind:color={wishlistColor} bind:color={wishlistColor}
@@ -137,12 +137,15 @@
</div> </div>
</div> </div>
<!-- Settings Card -->
<Card style={cardStyle}> <Card style={cardStyle}>
<CardContent class="pt-6 space-y-4"> <CardContent class="pt-6 space-y-4">
<!-- Description -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<Label for="wishlist-description">{t.form.descriptionOptional}</Label> <Label for="wishlist-description">{t.form.descriptionOptional}</Label>
<IconButton <button
type="button"
onclick={() => { onclick={() => {
if (editingDescription) { if (editingDescription) {
saveDescription(); saveDescription();
@@ -150,17 +153,15 @@
editingDescription = true; editingDescription = true;
} }
}} }}
color={wishlistColor} class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
size="sm" aria-label={editingDescription ? "Save description" : "Edit description"}
class="flex-shrink-0"
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> </button>
</div> </div>
{#if editingDescription} {#if editingDescription}
<Textarea <Textarea
@@ -169,40 +170,38 @@
class="w-full" class="w-full"
rows={3} rows={3}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === "Escape") {
wishlistDescription = wishlist.description || ''; wishlistDescription = wishlist.description || "";
editingDescription = false; editingDescription = false;
} }
}} }}
autofocus autofocus
/> />
{:else} {:else}
<div <div class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]">
class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]"
>
{wishlistDescription || t.form.noDescription} {wishlistDescription || t.form.noDescription}
</div> </div>
{/if} {/if}
</div> </div>
<!-- End Date -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
<Label for="wishlist-end-date">{t.form.endDateOptional}</Label> <Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if wishlistEndDate} {#if wishlistEndDate}
<IconButton <button
type="button"
onclick={clearEndDate} onclick={clearEndDate}
color={wishlistColor} class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
size="sm"
class="flex-shrink-0"
aria-label="Clear end date" aria-label="Clear end date"
> >
<X class="w-4 h-4" /> <X class="w-4 h-4" />
</IconButton> </button>
{/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"
/> />
+24 -24
View File
@@ -1,16 +1,16 @@
<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/server/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?: import('svelte').Snippet; children?: any;
showDragHandle?: boolean; showDragHandle?: boolean;
theme?: string | null; theme?: string | null;
wishlistColor?: string | null; wishlistColor?: string | null;
@@ -28,21 +28,24 @@
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(price: string | null, currency: string | null): string { function formatPrice(
if (!price) return ''; price: string | null,
const symbol = currency ? currencySymbols[currency] || currency : 'kr'; currency: string | null,
): string {
if (!price) return "";
const symbol = currency ? currencySymbols[currency] || currency : "kr";
const amount = parseFloat(price).toFixed(2); 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}`;
} }
@@ -54,7 +57,7 @@
</script> </script>
<Card style={cardStyle} class="relative overflow-hidden"> <Card style={cardStyle} class="relative overflow-hidden">
<ThemeCard themeName={theme} color={item.color} showPattern={false} /> <ThemeCard themeName={theme} color={item.color} />
<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}
@@ -75,7 +78,7 @@
src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}" src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}"
alt={item.title} alt={item.title}
class="w-full md:w-32 h-32 object-cover rounded-lg" class="w-full md:w-32 h-32 object-cover rounded-lg"
onerror={(e: Event) => ((e.currentTarget as HTMLImageElement).src = item.imageUrl)} onerror={(e) => e.currentTarget.src = item.imageUrl}
/> />
{/if} {/if}
@@ -85,17 +88,14 @@
</div> </div>
{#if item.description} {#if item.description}
<p <p class="text-muted-foreground break-words whitespace-pre-wrap" style="overflow-wrap: anywhere;">{item.description}</p>
class="text-muted-foreground break-words whitespace-pre-wrap"
style="overflow-wrap: anywhere;"
>
{item.description}
</p>
{/if} {/if}
<div class="flex flex-wrap gap-2 items-center text-sm mt-2"> <div class="flex flex-wrap gap-2 items-center text-sm mt-2">
{#if item.price} {#if item.price}
<span class="font-medium">{formatPrice(item.price, item.currency)}</span> <span class="font-medium"
>{formatPrice(item.price, item.currency)}</span
>
{/if} {/if}
</div> </div>
-193
View File
@@ -1,193 +0,0 @@
import { pgTable, text, timestamp, numeric, boolean, primaryKey } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2';
import type { AdapterAccountType } from '@auth/core/adapters';
export const users = pgTable('user', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password').notNull(),
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'),
lastLogin: timestamp('last_login', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
);
export const wishlists = pgTable('wishlists', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
theme: text('theme').default('none'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
}));
export const items = pgTable('items', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const itemsRelations = relations(items, ({ one, many }) => ({
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
}));
export const reservations = pgTable('reservations', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const reservationsRelations = relations(reservations, ({ one }) => ({
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
}),
user: one(users, {
fields: [reservations.userId],
references: [users.id]
})
}));
export const savedWishlists = pgTable('saved_wishlists', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
ownerToken: text('owner_token'), // Stores the owner token if user has edit access (claimed via edit link)
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
}));
export const usersRelations = relations(users, ({ many }) => ({
wishlists: many(wishlists),
savedWishlists: many(savedWishlists),
reservations: many(reservations)
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Account = typeof accounts.$inferSelect;
export type NewAccount = typeof accounts.$inferInsert;
export type Session = typeof sessions.$inferSelect;
export type NewSession = typeof sessions.$inferInsert;
export type VerificationToken = typeof verificationTokens.$inferSelect;
export type NewVerificationToken = typeof verificationTokens.$inferInsert;
export type Wishlist = typeof wishlists.$inferSelect;
export type NewWishlist = typeof wishlists.$inferInsert;
export type Item = typeof items.$inferSelect;
export type NewItem = typeof items.$inferInsert;
export type Reservation = typeof reservations.$inferSelect;
export type NewReservation = typeof reservations.$inferInsert;
export type SavedWishlist = typeof savedWishlists.$inferSelect;
export type NewSavedWishlist = typeof savedWishlists.$inferInsert;
+4 -8
View File
@@ -25,11 +25,9 @@ export const da: Translation = {
emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.', emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.',
emptyWishlistsAction: 'Opret Din Første Ønskeliste', emptyWishlistsAction: 'Opret Din Første Ønskeliste',
emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.', emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.',
emptyClaimedWishlistsDescription: emptyClaimedWishlistsDescription: 'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.', emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
emptySavedWishlistsDescription: emptySavedWishlistsDescription: 'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
by: 'af', by: 'af',
ends: 'Slutter', ends: 'Slutter',
welcomeBack: 'Velkommen tilbage', welcomeBack: 'Velkommen tilbage',
@@ -65,11 +63,9 @@ export const da: Translation = {
alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.', alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.',
alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.', alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.',
claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard', claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard',
claimedDescription: claimedDescription: 'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
deleteWishlist: 'Slet Ønskeliste', deleteWishlist: 'Slet Ønskeliste',
deleteConfirm: deleteConfirm: 'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
lockDeletion: 'Lås Sletning', lockDeletion: 'Lås Sletning',
unlockDeletion: 'Lås Op for Sletning', unlockDeletion: 'Lås Op for Sletning',
shareViewOnly: 'Del med venner (afslører reservationer)', shareViewOnly: 'Del med venner (afslører reservationer)',
+2 -4
View File
@@ -22,11 +22,9 @@ export const en = {
emptyWishlists: "You haven't created any wishlists yet.", emptyWishlists: "You haven't created any wishlists yet.",
emptyWishlistsAction: 'Create Your First Wishlist', emptyWishlistsAction: 'Create Your First Wishlist',
emptyClaimedWishlists: "You haven't claimed any wishlists yet.", emptyClaimedWishlists: "You haven't claimed any wishlists yet.",
emptyClaimedWishlistsDescription: emptyClaimedWishlistsDescription: "When someone shares an edit link with you, you can claim it to manage it from your dashboard.",
'When someone shares an edit link with you, you can claim it to manage it from your dashboard.',
emptySavedWishlists: "You haven't saved any wishlists yet.", emptySavedWishlists: "You haven't saved any wishlists yet.",
emptySavedWishlistsDescription: emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.",
"When viewing someone's wishlist, you can save it to easily find it later.",
by: 'by', by: 'by',
ends: 'Ends', ends: 'Ends',
welcomeBack: 'Welcome back', welcomeBack: 'Welcome back',
+1
View File
@@ -0,0 +1 @@
+1 -1
View File
@@ -1,7 +1,7 @@
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import * as schema from '$lib/db/schema'; import * as schema from './schema';
const client = postgres(env.DATABASE_URL!); const client = postgres(env.DATABASE_URL!);
export const db = drizzle(client, { schema }); export const db = drizzle(client, { schema });
+179
View File
@@ -0,0 +1,179 @@
import { pgTable, text, timestamp, numeric, boolean, primaryKey } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { createId } from '@paralleldrive/cuid2';
import type { AdapterAccountType } from '@auth/core/adapters';
export const users = pgTable('user', {
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
name: text('name'),
email: text('email').unique(),
emailVerified: timestamp('emailVerified', { mode: 'date' }),
image: text('image'),
password: text('password'),
username: text('username').unique(),
dashboardTheme: text('dashboard_theme').default('none'),
dashboardColor: text('dashboard_color'),
lastLogin: timestamp('last_login', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const accounts = pgTable(
'account',
{
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
type: text('type').$type<AdapterAccountType>().notNull(),
provider: text('provider').notNull(),
providerAccountId: text('providerAccountId').notNull(),
refresh_token: text('refresh_token'),
access_token: text('access_token'),
expires_at: numeric('expires_at'),
token_type: text('token_type'),
scope: text('scope'),
id_token: text('id_token'),
session_state: text('session_state')
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId]
})
})
);
export const sessions = pgTable('session', {
sessionToken: text('sessionToken').primaryKey(),
userId: text('userId')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expires: timestamp('expires', { mode: 'date' }).notNull()
});
export const verificationTokens = pgTable(
'verificationToken',
{
identifier: text('identifier').notNull(),
token: text('token').notNull(),
expires: timestamp('expires', { mode: 'date' }).notNull()
},
(verificationToken) => ({
compositePk: primaryKey({
columns: [verificationToken.identifier, verificationToken.token]
})
})
);
export const wishlists = pgTable('wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
title: text('title').notNull(),
description: text('description'),
ownerToken: text('owner_token').notNull().unique(),
publicToken: text('public_token').notNull().unique(),
isFavorite: boolean('is_favorite').default(false).notNull(),
color: text('color'),
theme: text('theme').default('none'),
endDate: timestamp('end_date', { mode: 'date' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
user: one(users, {
fields: [wishlists.userId],
references: [users.id]
}),
items: many(items),
savedBy: many(savedWishlists)
}));
export const items = pgTable('items', {
id: text('id').primaryKey().$defaultFn(() => createId()),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
link: text('link'),
imageUrl: text('image_url'),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').default('DKK'),
color: text('color'),
order: numeric('order').notNull().default('0'),
isReserved: boolean('is_reserved').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
export const itemsRelations = relations(items, ({ one, many }) => ({
wishlist: one(wishlists, {
fields: [items.wishlistId],
references: [wishlists.id]
}),
reservations: many(reservations)
}));
export const reservations = pgTable('reservations', {
id: text('id').primaryKey().$defaultFn(() => createId()),
itemId: text('item_id')
.notNull()
.references(() => items.id, { onDelete: 'cascade' }),
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
reserverName: text('reserver_name'),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const reservationsRelations = relations(reservations, ({ one }) => ({
item: one(items, {
fields: [reservations.itemId],
references: [items.id]
}),
user: one(users, {
fields: [reservations.userId],
references: [users.id]
})
}));
export const savedWishlists = pgTable('saved_wishlists', {
id: text('id').primaryKey().$defaultFn(() => createId()),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
wishlistId: text('wishlist_id')
.notNull()
.references(() => wishlists.id, { onDelete: 'cascade' }),
ownerToken: text('owner_token'), // Stores the owner token if user has edit access (claimed via edit link)
isFavorite: boolean('is_favorite').default(false).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull()
});
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
user: one(users, {
fields: [savedWishlists.userId],
references: [users.id]
}),
wishlist: one(wishlists, {
fields: [savedWishlists.wishlistId],
references: [wishlists.id]
})
}));
export const usersRelations = relations(users, ({ many }) => ({
wishlists: many(wishlists),
savedWishlists: many(savedWishlists),
reservations: many(reservations)
}));
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Wishlist = typeof wishlists.$inferSelect;
export type NewWishlist = typeof wishlists.$inferInsert;
export type Item = typeof items.$inferSelect;
export type NewItem = typeof items.$inferInsert;
export type Reservation = typeof reservations.$inferSelect;
export type NewReservation = typeof reservations.$inferInsert;
export type SavedWishlist = typeof savedWishlists.$inferSelect;
export type NewSavedWishlist = typeof savedWishlists.$inferInsert;
-131
View File
@@ -1,131 +0,0 @@
export function sanitizeString(
input: string | null | undefined,
maxLength: number = 1000
): string | null {
if (input === null || input === undefined) {
return null;
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
throw new Error(`Input exceeds maximum length of ${maxLength}`);
}
return trimmed;
}
export function sanitizeUrl(url: string | null | undefined): string | null {
if (!url) {
return null;
}
return url.trim();
}
export function sanitizeText(
input: string | null | undefined,
maxLength: number = 10000
): string | null {
if (input === null || input === undefined) {
return null;
}
const trimmed = input.trim();
if (trimmed.length > maxLength) {
throw new Error(`Text exceeds maximum length of ${maxLength}`);
}
return trimmed;
}
export function sanitizePrice(price: string | null | undefined): number | null {
if (!price) {
return null;
}
const parsed = parseFloat(price.trim());
if (isNaN(parsed)) {
throw new Error('Invalid price format');
}
if (parsed < 0) {
throw new Error('Price cannot be negative');
}
return parsed;
}
export function sanitizeColor(color: string | null | undefined): string | null {
if (!color) {
return null;
}
return color.trim();
}
export function sanitizeCurrency(currency: string | null | undefined): string {
if (!currency) {
return 'DKK';
}
return currency.trim().toUpperCase();
}
export function sanitizeUsername(username: string): string {
const trimmed = username.trim().toLowerCase();
if (trimmed.length < 3 || trimmed.length > 50) {
throw new Error('Username must be between 3 and 50 characters');
}
return trimmed;
}
export function sanitizeId(id: string | null | undefined): string {
if (!id) {
throw new Error('ID is required');
}
const trimmed = id.trim();
if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid ID');
}
return trimmed;
}
export function sanitizeToken(token: string | null | undefined): string {
if (!token) {
throw new Error('Token is required');
}
const trimmed = token.trim();
if (trimmed.length === 0 || trimmed.length > 100) {
throw new Error('Invalid token');
}
return trimmed;
}
// Zod schemas for type-safe form validation
import { z } from 'zod';
export const currencySchema = z.enum(['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP']);
export const itemSchema = z.object({
title: z.string().min(1, 'Title is required').max(255),
description: z.string().max(2000).optional().nullable(),
link: z.string().url('Invalid URL').optional().nullable(),
imageUrl: z.string().url('Invalid image URL').optional().nullable(),
price: z.coerce.number().nonnegative().optional().nullable(),
currency: currencySchema.default('DKK'),
color: z.string().optional().nullable(),
order: z.coerce.number().int().nonnegative().optional()
});
export const updateItemSchema = itemSchema.extend({
id: z.string().min(1, 'Item ID is required')
});
export const wishlistSchema = z.object({
title: z.string().min(1, 'Title is required').max(255),
description: z.string().max(2000).optional().nullable(),
color: z.string().optional().nullable(),
theme: z.string().optional().nullable().transform((val) => val || 'none'),
endDate: z.coerce.date().optional().nullable()
});
export const reorderSchema = z.array(
z.object({
id: z.string(),
order: z.number().int().nonnegative()
})
);
export type ItemFormData = z.infer<typeof itemSchema>;
export type UpdateItemFormData = z.infer<typeof updateItemSchema>;
export type WishlistFormData = z.infer<typeof wishlistSchema>;
+1 -1
View File
@@ -48,7 +48,7 @@ export const languageStore = new LanguageStore();
// Helper function to get nested translation value // Helper function to get nested translation value
export function t(path: string): string { export function t(path: string): string {
const keys = path.split('.'); const keys = path.split('.');
let value: unknown = languageStore.t; let value: any = languageStore.t;
for (const key of keys) { for (const key of keys) {
if (value && typeof value === 'object' && key in value) { if (value && typeof value === 'object' && key in value) {
+1 -2
View File
@@ -25,8 +25,7 @@ class ThemeStore {
private applyTheme() { private applyTheme() {
if (!browser) return; if (!browser) return;
const isDark = const isDark = this.current === 'dark' ||
this.current === 'dark' ||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); (this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
this.resolved = isDark ? 'dark' : 'light'; this.resolved = isDark ? 'dark' : 'light';
+4 -4
View File
@@ -1,13 +1,13 @@
import { clsx, type ClassValue } from 'clsx'; import { clsx, type ClassValue } from "clsx";
import { twMerge } from 'tailwind-merge'; import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T; export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T; export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>; export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null }; export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+8 -5
View File
@@ -36,7 +36,8 @@ export function addLocalWishlist(wishlist: LocalWishlist): void {
try { try {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
const exists = wishlists.some((w) => w.ownerToken === wishlist.ownerToken); // Check if already exists
const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken);
if (exists) return; if (exists) return;
wishlists.push(wishlist); wishlists.push(wishlist);
@@ -54,7 +55,7 @@ export function forgetLocalWishlist(ownerToken: string): void {
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);
@@ -79,7 +80,7 @@ export function clearLocalWishlists(): void {
*/ */
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);
} }
/** /**
@@ -90,8 +91,10 @@ export function toggleLocalFavorite(ownerToken: string): void {
try { try {
const wishlists = getLocalWishlists(); const wishlists = getLocalWishlists();
const updated = wishlists.map((w) => const updated = wishlists.map(w =>
w.ownerToken === ownerToken ? { ...w, isFavorite: !w.isFavorite } : w w.ownerToken === ownerToken
? { ...w, isFavorite: !w.isFavorite }
: w
); );
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated)); localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
} catch (error) { } catch (error) {
+14 -14
View File
@@ -7,12 +7,12 @@ type UpdateField = {
}; };
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) {
@@ -27,34 +27,34 @@ export async function updateTitle(title: string): Promise<boolean> {
} }
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;
+3 -15
View File
@@ -3,13 +3,7 @@
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea'; import { Textarea } from '$lib/components/ui/textarea';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
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 { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -83,13 +77,7 @@
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form <form onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4">
onsubmit={(e) => {
e.preventDefault();
createWishlist();
}}
class="space-y-4"
>
<div class="space-y-2"> <div class="space-y-2">
<Label for="title">{t.form.wishlistTitle}</Label> <Label for="title">{t.form.wishlistTitle}</Label>
<Input <Input
@@ -111,7 +99,7 @@
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Label for="color">{t.form.wishlistColor}</Label> <Label for="color">{t.form.wishlistColor}</Label>
<ColorPicker bind:color size="sm" /> <ColorPicker bind:color={color} size="sm" />
</div> </div>
</div> </div>
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}> <Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
+2 -22
View File
@@ -1,21 +1,5 @@
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
function isValidImageUrl(url: string): boolean {
try {
const parsedUrl = new URL(url);
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
return false;
}
const hostname = parsedUrl.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
return true;
} catch {
return false;
}
}
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const imageUrl = url.searchParams.get('url'); const imageUrl = url.searchParams.get('url');
@@ -23,19 +7,15 @@ export const GET: RequestHandler = async ({ url }) => {
return new Response('Image URL is required', { status: 400 }); return new Response('Image URL is required', { status: 400 });
} }
if (!isValidImageUrl(imageUrl)) {
return new Response('Invalid image URL', { status: 400 });
}
try { try {
// Fetch the image with proper headers to avoid blocking // Fetch the image with proper headers to avoid blocking
const response = await fetch(imageUrl, { const response = await fetch(imageUrl, {
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'
+39 -161
View File
@@ -1,22 +1,6 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
function isValidUrl(urlString: string): boolean {
try {
const url = new URL(urlString);
if (!['http:', 'https:'].includes(url.protocol)) {
return false;
}
const hostname = url.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
return false;
}
return true;
} catch {
return false;
}
}
export const POST: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => {
const { url } = await request.json(); const { url } = await request.json();
@@ -24,21 +8,14 @@ export const POST: RequestHandler = async ({ request }) => {
return json({ error: 'URL is required' }, { status: 400 }); return json({ error: 'URL is required' }, { status: 400 });
} }
if (!isValidUrl(url)) {
return json({ error: 'Invalid URL' }, { status: 400 });
}
try { try {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: {
'User-Agent': 'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept: 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9', 'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br', 'Referer': 'https://www.google.com/'
'Cache-Control': 'no-cache',
Pragma: 'no-cache'
} }
}); });
@@ -51,6 +28,13 @@ export const POST: RequestHandler = async ({ request }) => {
const origin = baseUrl.origin; const origin = baseUrl.origin;
const imageUrls: string[] = []; const imageUrls: string[] = [];
// Match various image source patterns
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
const twitterImageRegex = /<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
const jsonLdRegex = /"image"\s*:\s*"([^"]+)"/gi;
function toAbsoluteUrl(imgUrl: string): string { function toAbsoluteUrl(imgUrl: string): string {
if (imgUrl.startsWith('http')) { if (imgUrl.startsWith('http')) {
@@ -65,179 +49,73 @@ export const POST: RequestHandler = async ({ request }) => {
return `${origin}/${imgUrl}`; return `${origin}/${imgUrl}`;
} }
function isLikelyProductImage(url: string): boolean {
const lower = url.toLowerCase();
const badPatterns = [
'logo',
'icon',
'sprite',
'favicon',
'banner',
'footer',
'header',
'background',
'pattern',
'placeholder',
'thumbnail-small',
'btn',
'button',
'menu',
'nav',
'navigation',
'social',
'instagram',
'facebook',
'twitter',
'linkedin',
'pinterest'
];
if (badPatterns.some((pattern) => lower.includes(pattern))) {
return false;
}
if (url.endsWith('.svg')) {
return false;
}
if (lower.includes('data:image')) {
return false;
}
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
return false;
}
return true;
}
let match; let match;
// Priority 1: OpenGraph and Twitter meta tags (main product image) // Priority 1: OpenGraph and Twitter meta tags (usually the best product images)
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
const twitterImageRegex =
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
while ((match = ogImageRegex.exec(html)) !== null) { while ((match = ogImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); imageUrls.push(toAbsoluteUrl(match[1]));
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
} }
while ((match = twitterImageRegex.exec(html)) !== null) { while ((match = twitterImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (!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: JSON-LD structured data (common for e-commerce)
const jsonLdRegex =
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
while ((match = jsonLdRegex.exec(html)) !== null) { while ((match = jsonLdRegex.exec(html)) !== null) {
try {
const jsonStr = match[1];
const jsonData = JSON.parse(jsonStr);
function extractImages(obj: unknown, results: Set<string>) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
obj.forEach((item: unknown) => extractImages(item, results));
} else {
const record = obj as Record<string, unknown>;
for (const key in record) {
if (key === 'image' || key === 'thumbnail' || key === 'url') {
const val = record[key];
if (typeof val === 'string') {
const url = toAbsoluteUrl(val);
if (isLikelyProductImage(url)) {
results.add(url);
}
}
if (Array.isArray(val)) {
val.forEach((item: unknown) => {
if (typeof item === 'string') {
const url = toAbsoluteUrl(item);
if (isLikelyProductImage(url)) {
results.add(url);
}
}
});
}
} else if (typeof record[key] === 'object') {
extractImages(record[key], results);
}
}
}
}
const jsonImages = new Set<string>();
extractImages(jsonData, jsonImages);
jsonImages.forEach((img) => {
if (!imageUrls.includes(img)) {
imageUrls.push(img);
}
});
} catch {
// JSON parsing failed, continue
}
}
// Priority 3: Look for data-image attributes (common in React/SPA)
const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
while ((match = dataImageRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (!imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 3: data-src attributes (lazy loaded images)
while ((match = dataSrcRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (!imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 4: srcset attributes (responsive images) // Priority 4: srcset attributes (responsive images)
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
while ((match = srcsetRegex.exec(html)) !== null) { while ((match = srcsetRegex.exec(html)) !== null) {
const srcsetValue = match[1]; const srcsetValue = match[1];
// srcset can have multiple URLs with sizes, extract them
const srcsetUrls = srcsetValue.split(',').map((s) => { const srcsetUrls = srcsetValue.split(',').map((s) => {
const parts = s.trim().split(/\s+/); const parts = s.trim().split(/\s+/);
return parts[0]; return parts[0]; // Get the URL part before size descriptor
}); });
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 (!imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
} }
// Priority 5: data-src attributes (lazy loaded) // Priority 5: Regular img src attributes
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
while ((match = dataSrcRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
imageUrls.push(url);
}
}
// Priority 6: Regular img src attributes
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
while ((match = imgRegex.exec(html)) !== null) { while ((match = imgRegex.exec(html)) !== null) {
const url = toAbsoluteUrl(match[1]); const url = toAbsoluteUrl(match[1]);
if (isLikelyProductImage(url) && !imageUrls.includes(url)) { if (!imageUrls.includes(url)) {
imageUrls.push(url); imageUrls.push(url);
} }
} }
// Priority 7: Background images in style attributes (common in some e-commerce) const filteredImages = imageUrls.filter(
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi; (url) =>
while ((match = bgImageRegex.exec(html)) !== null) { !url.toLowerCase().includes('logo') &&
const url = toAbsoluteUrl(match[1]); !url.toLowerCase().includes('icon') &&
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) { !url.toLowerCase().includes('sprite') &&
imageUrls.push(url); !url.toLowerCase().includes('favicon') &&
} !url.endsWith('.svg') &&
} url.length < 1000 && // Increased limit for modern CDN URLs
!url.includes('data:image') // Skip data URLs
);
// Final filtering: remove very long URLs and duplicates return json({ images: filteredImages.slice(0, 30) });
const finalImages = [...new Set(imageUrls)].filter((url) => { } catch (error) {
return url.length < 2000 && isLikelyProductImage(url);
});
return json({ images: finalImages.slice(0, 30) });
} catch {
return json({ error: 'Failed to scrape images' }, { status: 500 }); return json({ error: 'Failed to scrape images' }, { status: 500 });
} }
}; };
+5 -2
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists } from '$lib/db/schema'; import { wishlists } from '$lib/server/schema';
import { eq, or } from 'drizzle-orm'; import { eq, or } from 'drizzle-orm';
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
@@ -9,7 +9,10 @@ export const GET: RequestHandler = async ({ params }) => {
// Find wishlist by either ownerToken or publicToken // Find wishlist by either ownerToken or publicToken
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
where: or(eq(wishlists.ownerToken, token), eq(wishlists.publicToken, token)), where: or(
eq(wishlists.ownerToken, token),
eq(wishlists.publicToken, token)
),
with: { with: {
items: { items: {
orderBy: (items, { asc }) => [asc(items.order)] orderBy: (items, { asc }) => [asc(items.order)]
+6 -19
View File
@@ -1,26 +1,13 @@
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists } from '$lib/db/schema'; import { wishlists } from '$lib/server/schema';
import { createId } from '@paralleldrive/cuid2'; import { createId } from '@paralleldrive/cuid2';
import { sanitizeString, sanitizeColor } from '$lib/server/validation';
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const body = await request.json(); const { title, description, color } = await request.json();
let title: string | null; if (!title?.trim()) {
let description: string | null;
let color: string | null;
try {
title = sanitizeString(body.title, 200);
description = sanitizeString(body.description, 2000);
color = sanitizeColor(body.color);
} catch {
return json({ error: 'Invalid input' }, { status: 400 });
}
if (!title) {
return json({ error: 'Title is required' }, { status: 400 }); return json({ error: 'Title is required' }, { status: 400 });
} }
@@ -33,9 +20,9 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const [wishlist] = await db const [wishlist] = await db
.insert(wishlists) .insert(wishlists)
.values({ .values({
title, title: title.trim(),
description, description: description?.trim() || null,
color, color: color?.trim() || null,
ownerToken, ownerToken,
publicToken, publicToken,
userId userId
+19 -22
View File
@@ -1,7 +1,7 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, savedWishlists, users } from '$lib/db/schema'; import { wishlists, savedWishlists, users } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
@@ -50,17 +50,15 @@ export const load: PageServerLoad = async (event) => {
// Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist) // Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist)
// This ensures users only see ownerToken if they claimed via edit link // This ensures users only see ownerToken if they claimed via edit link
const savedWithAccess = saved.map((s) => ({ const savedWithAccess = saved.map(s => ({
...s, ...s,
wishlist: s.wishlist wishlist: s.wishlist ? {
? {
...s.wishlist, ...s.wishlist,
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves) // Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
ownerToken: s.ownerToken, ownerToken: s.ownerToken,
// Keep publicToken as-is for viewing // Keep publicToken as-is for viewing
publicToken: s.wishlist.publicToken publicToken: s.wishlist.publicToken
} } : null
: null
})); }));
return { return {
@@ -86,8 +84,7 @@ export const actions: Actions = {
return { success: false, error: 'Wishlist ID is required' }; return { success: false, error: 'Wishlist ID is required' };
} }
await db await db.update(wishlists)
.update(wishlists)
.set({ isFavorite: !isFavorite, updatedAt: new Date() }) .set({ isFavorite: !isFavorite, updatedAt: new Date() })
.where(eq(wishlists.id, wishlistId)); .where(eq(wishlists.id, wishlistId));
@@ -108,8 +105,7 @@ export const actions: Actions = {
return { success: false, error: 'Saved wishlist ID is required' }; return { success: false, error: 'Saved wishlist ID is required' };
} }
await db await db.update(savedWishlists)
.update(savedWishlists)
.set({ isFavorite: !isFavorite }) .set({ isFavorite: !isFavorite })
.where(eq(savedWishlists.id, savedWishlistId)); .where(eq(savedWishlists.id, savedWishlistId));
@@ -129,11 +125,11 @@ export const actions: Actions = {
return { success: false, error: 'Saved wishlist ID is required' }; return { success: false, error: 'Saved wishlist ID is required' };
} }
await db await db.delete(savedWishlists)
.delete(savedWishlists) .where(and(
.where( eq(savedWishlists.id, savedWishlistId),
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id)) eq(savedWishlists.userId, session.user.id)
); ));
return { success: true }; return { success: true };
}, },
@@ -151,9 +147,12 @@ export const actions: Actions = {
return { success: false, error: 'Wishlist ID is required' }; return { success: false, error: 'Wishlist ID is required' };
} }
await db // Verify the user owns this wishlist
.delete(wishlists) await db.delete(wishlists)
.where(and(eq(wishlists.id, wishlistId), eq(wishlists.userId, session.user.id))); .where(and(
eq(wishlists.id, wishlistId),
eq(wishlists.userId, session.user.id)
));
return { success: true }; return { success: true };
}, },
@@ -171,8 +170,7 @@ export const actions: Actions = {
return { success: false, error: 'Theme is required' }; return { success: false, error: 'Theme is required' };
} }
await db await db.update(users)
.update(users)
.set({ dashboardTheme: theme, updatedAt: new Date() }) .set({ dashboardTheme: theme, updatedAt: new Date() })
.where(eq(users.id, session.user.id)); .where(eq(users.id, session.user.id));
@@ -188,8 +186,7 @@ export const actions: Actions = {
const formData = await request.formData(); const formData = await request.formData();
const color = formData.get('color') as string | null; const color = formData.get('color') as string | null;
await db await db.update(users)
.update(users)
.set({ dashboardColor: color, updatedAt: new Date() }) .set({ dashboardColor: color, updatedAt: new Date() })
.where(eq(users.id, session.user.id)); .where(eq(users.id, session.user.id));
+38 -50
View File
@@ -6,15 +6,17 @@
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();
// For anonymous users, get theme from localStorage
function getInitialTheme() { function getInitialTheme() {
if (data.isAuthenticated) { if (data.isAuthenticated) {
return data.user?.dashboardTheme || 'none'; return data.user?.dashboardTheme || 'none';
} else { } else {
// Anonymous user - get from localStorage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardTheme') || 'none'; return localStorage.getItem('dashboardTheme') || 'none';
} }
@@ -22,10 +24,12 @@
} }
} }
// For anonymous users, get color from localStorage
function getInitialColor() { function getInitialColor() {
if (data.isAuthenticated) { if (data.isAuthenticated) {
return data.user?.dashboardColor || null; return data.user?.dashboardColor || null;
} else { } else {
// Anonymous user - get from localStorage
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
return localStorage.getItem('dashboardColor') || null; return localStorage.getItem('dashboardColor') || null;
} }
@@ -36,6 +40,7 @@
let currentTheme = $state(getInitialTheme()); let currentTheme = $state(getInitialTheme());
let currentColor = $state(getInitialColor()); let currentColor = $state(getInitialColor());
// Save to localStorage when theme changes for anonymous users
function handleThemeUpdate(theme: string | null) { function handleThemeUpdate(theme: string | null) {
currentTheme = theme || 'none'; currentTheme = theme || 'none';
@@ -44,6 +49,7 @@
} }
} }
// Save to localStorage when color changes for anonymous users
function handleColorUpdate(color: string | null) { function handleColorUpdate(color: string | null) {
currentColor = color; currentColor = color;
@@ -58,12 +64,14 @@
const t = $derived(languageStore.t); const t = $derived(languageStore.t);
// Only owned wishlists for "My Wishlists"
const myWishlists = $derived(() => data.wishlists || []); const myWishlists = $derived(() => data.wishlists || []);
// Claimed wishlists (those with ownerToken, meaning they were claimed via edit link)
const claimedWishlists = $derived(() => { const claimedWishlists = $derived(() => {
return (data.savedWishlists || []) return (data.savedWishlists || [])
.filter((saved) => saved.wishlist?.ownerToken) .filter(saved => saved.wishlist?.ownerToken)
.map((saved) => ({ .map(saved => ({
...saved.wishlist, ...saved.wishlist,
isFavorite: saved.isFavorite, isFavorite: saved.isFavorite,
isClaimed: true, isClaimed: true,
@@ -71,8 +79,9 @@
})); }));
}); });
// Saved wishlists are those WITHOUT ownerToken (saved from public view only)
const savedWishlists = $derived(() => { const savedWishlists = $derived(() => {
return (data.savedWishlists || []).filter((saved) => !saved.wishlist?.ownerToken); return (data.savedWishlists || []).filter(saved => !saved.wishlist?.ownerToken);
}); });
</script> </script>
@@ -87,7 +96,15 @@
onColorUpdate={handleColorUpdate} onColorUpdate={handleColorUpdate}
/> />
<!-- Local Wishlists Section (for anonymous and authenticated users) -->
<LocalWishlistsSection
isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
/>
{#if data.isAuthenticated} {#if data.isAuthenticated}
<!-- My Wishlists Section -->
<WishlistSection <WishlistSection
title={t.dashboard.myWishlists} title={t.dashboard.myWishlists}
description={t.dashboard.myWishlistsDescription} description={t.dashboard.myWishlistsDescription}
@@ -101,19 +118,15 @@
> >
{#snippet actions(wishlist, unlocked)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form <form method="POST" action="?/toggleFavorite" use:enhance={() => {
method="POST"
action="?/toggleFavorite"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="wishlistId" value={wishlist.id} /> <input type="hidden" name="wishlistId" value={wishlist.id} />
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} /> <input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
<Button type="submit" size="sm" variant="outline"> <Button type="submit" size="sm" variant="outline">
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> <Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
</Button> </Button>
</form> </form>
<Button <Button
@@ -134,15 +147,11 @@
{t.dashboard.copyLink} {t.dashboard.copyLink}
</Button> </Button>
{#if unlocked} {#if unlocked}
<form <form method="POST" action="?/deleteWishlist" use:enhance={() => {
method="POST"
action="?/deleteWishlist"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="wishlistId" value={wishlist.id} /> <input type="hidden" name="wishlistId" value={wishlist.id} />
<Button type="submit" size="sm" variant="destructive"> <Button type="submit" size="sm" variant="destructive">
{t.dashboard.delete} {t.dashboard.delete}
@@ -153,12 +162,7 @@
{/snippet} {/snippet}
</WishlistSection> </WishlistSection>
<LocalWishlistsSection <!-- Claimed Wishlists Section -->
isAuthenticated={data.isAuthenticated}
fallbackColor={currentColor}
fallbackTheme={currentTheme}
/>
<WishlistSection <WishlistSection
title={t.dashboard.claimedWishlists} title={t.dashboard.claimedWishlists}
description={t.dashboard.claimedWishlistsDescription} description={t.dashboard.claimedWishlistsDescription}
@@ -171,19 +175,15 @@
> >
{#snippet actions(wishlist, unlocked)} {#snippet actions(wishlist, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form <form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
method="POST"
action="?/toggleSavedFavorite"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} /> <input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} /> <input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
<Button type="submit" size="sm" variant="outline"> <Button type="submit" size="sm" variant="outline">
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> <Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
</Button> </Button>
</form> </form>
<Button <Button
@@ -204,15 +204,11 @@
{t.dashboard.copyLink} {t.dashboard.copyLink}
</Button> </Button>
{#if unlocked} {#if unlocked}
<form <form method="POST" action="?/unsaveWishlist" use:enhance={() => {
method="POST"
action="?/unsaveWishlist"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} /> <input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
<Button type="submit" size="sm" variant="destructive"> <Button type="submit" size="sm" variant="destructive">
{t.dashboard.unclaim} {t.dashboard.unclaim}
@@ -223,6 +219,7 @@
{/snippet} {/snippet}
</WishlistSection> </WishlistSection>
<!-- Saved Wishlists Section -->
<WishlistSection <WishlistSection
title={t.dashboard.savedWishlists} title={t.dashboard.savedWishlists}
description={t.dashboard.savedWishlistsDescription} description={t.dashboard.savedWishlistsDescription}
@@ -231,23 +228,18 @@
emptyDescription={t.dashboard.emptySavedWishlistsDescription} emptyDescription={t.dashboard.emptySavedWishlistsDescription}
fallbackColor={currentColor} fallbackColor={currentColor}
fallbackTheme={currentTheme} fallbackTheme={currentTheme}
hideIfEmpty={true}
> >
{#snippet actions(saved, unlocked)} {#snippet actions(saved, unlocked)}
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<form <form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
method="POST"
action="?/toggleSavedFavorite"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="savedWishlistId" value={saved.id} /> <input type="hidden" name="savedWishlistId" value={saved.id} />
<input type="hidden" name="isFavorite" value={saved.isFavorite} /> <input type="hidden" name="isFavorite" value={saved.isFavorite} />
<Button type="submit" size="sm" variant="outline"> <Button type="submit" size="sm" variant="outline">
<Star class={saved.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} /> <Star class={saved.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
</Button> </Button>
</form> </form>
<Button <Button
@@ -257,15 +249,11 @@
{t.dashboard.viewWishlist} {t.dashboard.viewWishlist}
</Button> </Button>
{#if unlocked} {#if unlocked}
<form <form method="POST" action="?/unsaveWishlist" use:enhance={() => {
method="POST"
action="?/unsaveWishlist"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="savedWishlistId" value={saved.id} /> <input type="hidden" name="savedWishlistId" value={saved.id} />
<Button type="submit" size="sm" variant="destructive"> <Button type="submit" size="sm" variant="destructive">
{t.dashboard.unsave} {t.dashboard.unsave}
+4 -14
View File
@@ -1,12 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} 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 { ThemeToggle } from '$lib/components/ui/theme-toggle'; import { ThemeToggle } from '$lib/components/ui/theme-toggle';
@@ -55,17 +49,13 @@
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
{#if data.registered} {#if data.registered}
<div <div class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded">
class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded"
>
Account created successfully! Please sign in. Account created successfully! Please sign in.
</div> </div>
{/if} {/if}
{#if data.error} {#if data.error}
<div <div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded"
>
Invalid username or password Invalid username or password
</div> </div>
{/if} {/if}
@@ -96,7 +86,7 @@
</div> </div>
</div> </div>
{#each data.oauthProviders as provider (provider.id)} {#each data.oauthProviders as provider}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
+11 -19
View File
@@ -1,11 +1,10 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { users, type NewUser } from '$lib/db/schema'; import { users } from '$lib/server/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { sanitizeString, sanitizeUsername } from '$lib/server/validation';
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async () => {
// Determine which OAuth providers are available // Determine which OAuth providers are available
@@ -32,18 +31,12 @@ export const actions: Actions = {
const password = formData.get('password') as string; const password = formData.get('password') as string;
const confirmPassword = formData.get('confirmPassword') as string; const confirmPassword = formData.get('confirmPassword') as string;
let sanitizedUsername: string; if (!name?.trim()) {
let sanitizedName: string | null; return fail(400, { error: 'Name is required', name, username });
try {
sanitizedName = sanitizeString(name, 100);
sanitizedUsername = sanitizeUsername(username);
} catch {
return fail(400, { error: 'Invalid input', name, username });
} }
if (!sanitizedName) { if (!username?.trim()) {
return fail(400, { error: 'Name is required', name, username }); return fail(400, { error: 'Username is required', name, username });
} }
if (!password || password.length < 8) { if (!password || password.length < 8) {
@@ -55,21 +48,20 @@ export const actions: Actions = {
} }
const existingUser = await db.query.users.findFirst({ const existingUser = await db.query.users.findFirst({
where: eq(users.username, sanitizedUsername) where: eq(users.username, username.trim().toLowerCase())
}); });
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, 10);
const newUser: NewUser = { await db.insert(users).values({
name: sanitizedName, name: name.trim(),
username: sanitizedUsername, username: username.trim().toLowerCase(),
password: hashedPassword password: hashedPassword
}; });
await db.insert(users).values(newUser);
throw redirect(303, '/signin?registered=true'); throw redirect(303, '/signin?registered=true');
} }
+4 -18
View File
@@ -1,12 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} 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 { ThemeToggle } from '$lib/components/ui/theme-toggle'; import { ThemeToggle } from '$lib/components/ui/theme-toggle';
@@ -32,9 +26,7 @@
</CardHeader> </CardHeader>
<CardContent class="space-y-4"> <CardContent class="space-y-4">
{#if form?.error} {#if form?.error}
<div <div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded"
>
{form.error} {form.error}
</div> </div>
{/if} {/if}
@@ -57,13 +49,7 @@
<div class="space-y-2"> <div class="space-y-2">
<Label for="confirmPassword">{t.form.confirmPassword}</Label> <Label for="confirmPassword">{t.form.confirmPassword}</Label>
<Input <Input id="confirmPassword" name="confirmPassword" type="password" required minlength={8} />
id="confirmPassword"
name="confirmPassword"
type="password"
required
minlength={8}
/>
</div> </div>
<Button type="submit" class="w-full">{t.auth.signUp}</Button> <Button type="submit" class="w-full">{t.auth.signUp}</Button>
@@ -79,7 +65,7 @@
</div> </div>
</div> </div>
{#each data.oauthProviders as provider (provider.id)} {#each data.oauthProviders as provider}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
+17 -9
View File
@@ -1,7 +1,7 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, items, reservations, savedWishlists, type NewSavedWishlist } from '$lib/db/schema'; import { wishlists, items, reservations, savedWishlists } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
@@ -34,7 +34,7 @@ export const load: PageServerLoad = async ({ params, locals }) => {
) )
}); });
isSaved = !!saved; isSaved = !!saved;
isClaimed = !!saved?.ownerToken; isClaimed = !!saved?.ownerToken; // User has claimed if ownerToken exists in savedWishlists
savedWishlistId = saved?.id || null; savedWishlistId = saved?.id || null;
} }
@@ -75,7 +75,10 @@ export const actions: Actions = {
reserverName: reserverName?.trim() || null reserverName: reserverName?.trim() || null
}); });
await tx.update(items).set({ isReserved: true }).where(eq(items.id, itemId)); await tx
.update(items)
.set({ isReserved: true })
.where(eq(items.id, itemId));
}); });
return { success: true }; return { success: true };
@@ -111,13 +114,16 @@ export const actions: Actions = {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.delete(reservations).where(eq(reservations.itemId, itemId)); await tx.delete(reservations).where(eq(reservations.itemId, itemId));
await tx.update(items).set({ isReserved: false }).where(eq(items.id, itemId)); await tx
.update(items)
.set({ isReserved: false })
.where(eq(items.id, itemId));
}); });
return { success: true }; return { success: true };
}, },
saveWishlist: async ({ request, locals }) => { saveWishlist: async ({ request, locals, params }) => {
const session = await locals.auth(); const session = await locals.auth();
if (!session?.user?.id) { if (!session?.user?.id) {
@@ -143,12 +149,11 @@ export const actions: Actions = {
} }
// Save without ownerToken - user is accessing via public link, so no edit access // Save without ownerToken - user is accessing via public link, so no edit access
const newSavedWishlist: NewSavedWishlist = { await db.insert(savedWishlists).values({
userId: session.user.id, userId: session.user.id,
wishlistId, wishlistId,
ownerToken: null // Explicitly set to null - no edit access from reservation view ownerToken: null // Explicitly set to null - no edit access from reservation view
}; });
await db.insert(savedWishlists).values(newSavedWishlist);
return { success: true }; return { success: true };
}, },
@@ -170,7 +175,10 @@ export const actions: Actions = {
await db await db
.delete(savedWishlists) .delete(savedWishlists)
.where( .where(
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id)) and(
eq(savedWishlists.id, savedWishlistId),
eq(savedWishlists.userId, session.user.id)
)
); );
return { success: true }; return { success: true };
+43 -34
View File
@@ -3,20 +3,21 @@
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardTitle CardHeader,
} from '$lib/components/ui/card'; CardTitle,
import { Button } from '$lib/components/ui/button'; } from "$lib/components/ui/card";
import type { PageData } from './$types'; import { Button } from "$lib/components/ui/button";
import WishlistItem from '$lib/components/wishlist/WishlistItem.svelte'; import type { PageData } from "./$types";
import ReservationButton from '$lib/components/wishlist/ReservationButton.svelte'; import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
import PageContainer from '$lib/components/layout/PageContainer.svelte'; import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte";
import Navigation from '$lib/components/layout/Navigation.svelte'; import PageContainer from "$lib/components/layout/PageContainer.svelte";
import EmptyState from '$lib/components/layout/EmptyState.svelte'; import Navigation from "$lib/components/layout/Navigation.svelte";
import { enhance } from '$app/forms'; import EmptyState from "$lib/components/layout/EmptyState.svelte";
import { getCardStyle } from '$lib/utils/colors'; import { enhance } from "$app/forms";
import { 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();
@@ -26,8 +27,7 @@
const headerCardStyle = $derived(getCardStyle(data.wishlist.color)); const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
const filteredItems = $derived( const filteredItems = $derived(
data.wishlist.items?.filter( data.wishlist.items?.filter(item =>
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) || item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) item.description?.toLowerCase().includes(searchQuery.toLowerCase())
) || [] ) || []
@@ -38,7 +38,6 @@
<Navigation <Navigation
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
showDashboardLink={true} showDashboardLink={true}
color={data.wishlist.color}
/> />
<Card style={headerCardStyle}> <Card style={headerCardStyle}>
@@ -47,31 +46,37 @@
<div class="flex-1"> <div class="flex-1">
<CardTitle class="text-3xl">{data.wishlist.title}</CardTitle> <CardTitle class="text-3xl">{data.wishlist.title}</CardTitle>
{#if data.wishlist.description} {#if data.wishlist.description}
<CardDescription class="text-base">{data.wishlist.description}</CardDescription> <CardDescription class="text-base"
>{data.wishlist.description}</CardDescription
>
{/if} {/if}
</div> </div>
{#if data.isAuthenticated} {#if data.isAuthenticated}
{#if data.isClaimed} {#if data.isClaimed}
<Button variant="outline" size="sm" disabled> <Button
variant="outline"
size="sm"
disabled
>
{t.wishlist.youClaimedThis} {t.wishlist.youClaimedThis}
</Button> </Button>
{:else if data.isSaved} {:else if data.isSaved}
<form method="POST" action="?/unsaveWishlist" use:enhance> <form method="POST" action="?/unsaveWishlist" use:enhance>
<input type="hidden" name="savedWishlistId" value={data.savedWishlistId} /> <input
type="hidden"
name="savedWishlistId"
value={data.savedWishlistId}
/>
<Button type="submit" variant="outline" size="sm"> <Button type="submit" variant="outline" size="sm">
{t.wishlist.unsaveWishlist} {t.wishlist.unsaveWishlist}
</Button> </Button>
</form> </form>
{:else} {:else}
<form <form method="POST" action="?/saveWishlist" use:enhance={() => {
method="POST"
action="?/saveWishlist"
use:enhance={() => {
return async ({ update }) => { return async ({ update }) => {
await update({ reset: false }); await update({ reset: false });
}; };
}} }}>
>
<input type="hidden" name="wishlistId" value={data.wishlist.id} /> <input type="hidden" name="wishlistId" value={data.wishlist.id} />
<Button type="submit" variant="outline" size="sm"> <Button type="submit" variant="outline" size="sm">
{t.wishlist.saveWishlist} {t.wishlist.saveWishlist}
@@ -79,7 +84,11 @@
</form> </form>
{/if} {/if}
{:else} {:else}
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')}> <Button
variant="outline"
size="sm"
onclick={() => (window.location.href = "/signin")}
>
{t.wishlist.signInToSave} {t.wishlist.signInToSave}
</Button> </Button>
{/if} {/if}
@@ -93,7 +102,7 @@
<div class="space-y-4"> <div class="space-y-4">
{#if filteredItems.length > 0} {#if filteredItems.length > 0}
{#each filteredItems as item (item.id)} {#each filteredItems as item}
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}> <WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
<ReservationButton <ReservationButton
itemId={item.id} itemId={item.id}
@@ -108,18 +117,18 @@
<Card style={headerCardStyle} class="relative overflow-hidden"> <Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} /> <ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
<CardContent class="p-12 relative z-10"> <CardContent class="p-12 relative z-10">
<EmptyState message="No wishes match your search." /> <EmptyState
message="No wishes match your search."
/>
</CardContent> </CardContent>
</Card> </Card>
{:else} {:else}
<Card style={headerCardStyle} class="relative overflow-hidden"> <Card style={headerCardStyle} class="relative overflow-hidden">
<ThemeCard <ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
themeName={data.wishlist.theme}
color={data.wishlist.color}
showPattern={false}
/>
<CardContent class="p-12 relative z-10"> <CardContent class="p-12 relative z-10">
<EmptyState message={t.wishlist.emptyWishes} /> <EmptyState
message={t.wishlist.emptyWishes}
/>
</CardContent> </CardContent>
</Card> </Card>
{/if} {/if}
@@ -1,9 +1,8 @@
import { error, fail } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { db } from '$lib/server/db'; import { db } from '$lib/server/db';
import { wishlists, items, savedWishlists, type NewItem, type NewSavedWishlist } from '$lib/db/schema'; import { wishlists, items, savedWishlists } from '$lib/server/schema';
import { eq, and } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
import { itemSchema, updateItemSchema, wishlistSchema, reorderSchema } from '$lib/server/validation';
export const load: PageServerLoad = async ({ params, locals }) => { export const load: PageServerLoad = async ({ params, locals }) => {
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
@@ -49,45 +48,67 @@ export const load: PageServerLoad = async ({ params, locals }) => {
export const actions: Actions = { export const actions: Actions = {
addItem: async ({ params, request }) => { addItem: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const rawData = Object.fromEntries(formData); const title = formData.get('title') as string;
const description = formData.get('description') as string;
const link = formData.get('link') as string;
const imageUrl = formData.get('imageUrl') as string;
const price = formData.get('price') as string;
const currency = formData.get('currency') as string;
const color = formData.get('color') as string;
const result = itemSchema.safeParse(rawData); if (!title?.trim()) {
if (!result.success) { return { success: false, error: 'Title is required' };
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
} }
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: { items: true } with: {
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
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);
const newItem: NewItem = { await db.insert(items).values({
wishlistId: wishlist.id, wishlistId: wishlist.id,
...result.data, title: title.trim(),
price: result.data.price?.toString() || null, description: description?.trim() || null,
link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK',
color: color?.trim() || null,
order: String(maxOrder + 1) order: String(maxOrder + 1)
}; });
await db.insert(items).values(newItem);
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 rawData = Object.fromEntries(formData); const itemId = formData.get('itemId') as string;
const title = formData.get('title') as string;
const description = formData.get('description') as string;
const link = formData.get('link') as string;
const imageUrl = formData.get('imageUrl') as string;
const price = formData.get('price') as string;
const currency = formData.get('currency') as string;
const color = formData.get('color') as string;
const result = updateItemSchema.safeParse(rawData); if (!itemId) {
if (!result.success) { return { success: false, error: 'Item ID is required' };
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') }); }
if (!title?.trim()) {
return { success: false, error: 'Title is required' };
} }
const wishlist = await db.query.wishlists.findFirst({ const wishlist = await db.query.wishlists.findFirst({
@@ -98,15 +119,18 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const { id, ...data } = result.data; await db.update(items)
await db
.update(items)
.set({ .set({
...data, title: title.trim(),
price: data.price?.toString() || null, description: description?.trim() || null,
link: link?.trim() || null,
imageUrl: imageUrl?.trim() || null,
price: price ? price.trim() : null,
currency: currency?.trim() || 'DKK',
color: color?.trim() || null,
updatedAt: new Date() updatedAt: new Date()
}) })
.where(eq(items.id, id)); .where(eq(items.id, itemId));
return { success: true }; return { success: true };
}, },
@@ -136,6 +160,10 @@ export const actions: Actions = {
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) {
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)
}); });
@@ -144,14 +172,10 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
const result = reorderSchema.safeParse(JSON.parse(itemsJson || '[]')); const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
if (!result.success) {
return fail(400, { success: false, error: 'Invalid reorder data' });
}
for (const update of result.data) { for (const update of updates) {
await db await db.update(items)
.update(items)
.set({ order: String(update.order), updatedAt: new Date() }) .set({ order: String(update.order), updatedAt: new Date() })
.where(eq(items.id, update.id)); .where(eq(items.id, update.id));
} }
@@ -175,15 +199,11 @@ export const actions: Actions = {
updateWishlist: async ({ params, request }) => { updateWishlist: async ({ params, request }) => {
const formData = await request.formData(); const formData = await request.formData();
const rawData = Object.fromEntries(formData); const color = formData.get('color');
const title = formData.get('title');
// Only validate fields that are present const description = formData.get('description');
const partialSchema = wishlistSchema.partial(); const endDate = formData.get('endDate');
const result = partialSchema.safeParse(rawData); const theme = formData.get('theme');
if (!result.success) {
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
}
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)
@@ -193,12 +213,37 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
await db const updates: any = {
.update(wishlists)
.set({
...result.data,
updatedAt: new Date() updatedAt: new Date()
}) };
if (color !== null) {
updates.color = color?.toString().trim() || null;
}
if (title !== null) {
const titleStr = title?.toString().trim();
if (!titleStr) {
return { success: false, error: 'Title is required' };
}
updates.title = titleStr;
}
if (description !== null) {
updates.description = description?.toString().trim() || null;
}
if (endDate !== null) {
const endDateStr = endDate?.toString().trim();
updates.endDate = endDateStr ? new Date(endDateStr) : null;
}
if (theme !== null) {
updates.theme = theme?.toString().trim() || 'none';
}
await db.update(wishlists)
.set(updates)
.where(eq(wishlists.id, wishlist.id)); .where(eq(wishlists.id, wishlist.id));
return { success: true }; return { success: true };
@@ -232,13 +277,12 @@ export const actions: Actions = {
} }
// 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
const newSavedWishlist: NewSavedWishlist = { 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
}; });
await db.insert(savedWishlists).values(newSavedWishlist);
return { success: true, message: 'Wishlist claimed successfully' }; return { success: true, message: 'Wishlist claimed successfully' };
}, },
@@ -258,10 +302,11 @@ export const actions: Actions = {
throw error(404, 'Wishlist not found'); throw error(404, 'Wishlist not found');
} }
await db await db.delete(savedWishlists).where(
.delete(savedWishlists) and(
.where( eq(savedWishlists.userId, session.user.id),
and(eq(savedWishlists.userId, session.user.id), eq(savedWishlists.wishlistId, wishlist.id)) eq(savedWishlists.wishlistId, wishlist.id)
)
); );
return { success: true, message: 'Wishlist unclaimed' }; return { success: true, message: 'Wishlist unclaimed' };
+35 -33
View File
@@ -1,18 +1,18 @@
<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();
@@ -21,21 +21,22 @@
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((a, b) => Number(a.order) - Number(b.order)); const sorted = [...data.wishlist.items].sort(
(a, b) => Number(a.order) - Number(b.order),
);
items = sorted; items = sorted;
}); });
let filteredItems = $derived( let filteredItems = $derived(
searchQuery.trim() searchQuery.trim()
? items.filter( ? items.filter(item =>
(item) =>
item.title.toLowerCase().includes(searchQuery.toLowerCase()) || item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
item.description?.toLowerCase().includes(searchQuery.toLowerCase()) item.description?.toLowerCase().includes(searchQuery.toLowerCase())
) )
@@ -55,14 +56,16 @@
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) => (item.id === itemId ? { ...item, color: newColor } : item)); items = items.map((item) =>
item.id === itemId ? { ...item, color: newColor } : item,
);
} }
function cancelEditing() { function cancelEditing() {
@@ -72,7 +75,7 @@
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);
} }
@@ -82,8 +85,8 @@
if (showAddForm) { if (showAddForm) {
setTimeout(() => { setTimeout(() => {
addFormElement?.scrollIntoView({ addFormElement?.scrollIntoView({
behavior: 'smooth', behavior: "smooth",
block: 'center' block: "center",
}); });
}, 100); }, 100);
} }
@@ -92,7 +95,7 @@
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
@@ -120,7 +123,6 @@
<Navigation <Navigation
isAuthenticated={data.isAuthenticated} isAuthenticated={data.isAuthenticated}
showDashboardLink={true} showDashboardLink={true}
color={currentColor}
/> />
<WishlistHeader <WishlistHeader
@@ -145,15 +147,15 @@
ownerToken={data.wishlist.ownerToken} ownerToken={data.wishlist.ownerToken}
/> />
<WishlistActionButtons bind:rearranging {showAddForm} onToggleAddForm={handleToggleAddForm} /> <WishlistActionButtons
bind:rearranging={rearranging}
showAddForm={showAddForm}
onToggleAddForm={handleToggleAddForm}
/>
{#if showAddForm} {#if showAddForm}
<div bind:this={addFormElement}> <div bind:this={addFormElement}>
<AddItemForm <AddItemForm onSuccess={handleItemAdded} wishlistColor={currentColor} wishlistTheme={currentTheme} />
onSuccess={handleItemAdded}
wishlistColor={currentColor}
wishlistTheme={currentTheme}
/>
</div> </div>
{/if} {/if}
@@ -164,7 +166,7 @@
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}
+1 -8
View File
@@ -10,14 +10,7 @@ const config = {
kit: { kit: {
adapter: adapter({ adapter: adapter({
out: 'build' out: 'build'
}), })
alias: {
$db: './src/lib/db',
$components: './src/lib/components',
$utils: './src/lib/utils',
$stores: './src/lib/stores',
$i18n: './src/lib/i18n'
}
} }
}; };