Compare commits
14 Commits
686b43bc18
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37870c8403 | ||
|
|
07c098df77 | ||
|
|
6d3a418525 | ||
|
|
563ee5699b | ||
|
|
d63535ad1b | ||
|
|
7084f703dc | ||
|
|
1808f6d0ac | ||
|
|
1089a6eb3a | ||
|
|
fad19a9aa0 | ||
|
|
bc680fb60b | ||
|
|
6ae82d758e | ||
|
|
35c1ab64e8 | ||
|
|
6c73a7740c | ||
|
|
06c96f4b35 |
8
.phase.json
Normal file
8
.phase.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"version": "2",
|
||||||
|
"phaseApp": "wishlist",
|
||||||
|
"appId": "a4d85c7a-8df9-462b-9b91-5cb2957cdcd3",
|
||||||
|
"defaultEnv": "Production",
|
||||||
|
"envId": "496d0105-f2b4-424d-a1a1-a60602fc2252",
|
||||||
|
"monorepoSupport": false
|
||||||
|
}
|
||||||
15
.prettierignore
Normal file
15
.prettierignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# 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
.prettierrc
Normal file
9
.prettierrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
@@ -35,11 +35,13 @@ docker run -d \
|
|||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
Required:
|
Required:
|
||||||
|
|
||||||
- `DATABASE_URL` - PostgreSQL connection string
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
- `NODE_ENV` - Set to `production`
|
- `NODE_ENV` - Set to `production`
|
||||||
- `PORT` - Default `3000`
|
- `PORT` - Default `3000`
|
||||||
|
|
||||||
Optional (Docker Compose):
|
Optional (Docker Compose):
|
||||||
|
|
||||||
- `POSTGRES_USER` - Database user (default: `wishlistuser`)
|
- `POSTGRES_USER` - Database user (default: `wishlistuser`)
|
||||||
- `POSTGRES_PASSWORD` - Database password (default: `wishlistpassword`)
|
- `POSTGRES_PASSWORD` - Database password (default: `wishlistpassword`)
|
||||||
- `POSTGRES_DB` - Database name (default: `wishlist`)
|
- `POSTGRES_DB` - Database name (default: `wishlist`)
|
||||||
@@ -61,6 +63,7 @@ docker exec -it wishlist-app bun run db:push
|
|||||||
## Migrations
|
## Migrations
|
||||||
|
|
||||||
Production migrations:
|
Production migrations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec -it wishlist-app bun run db:migrate
|
docker exec -it wishlist-app bun run db:migrate
|
||||||
```
|
```
|
||||||
|
|||||||
19
Dockerfile
19
Dockerfile
@@ -1,13 +1,16 @@
|
|||||||
# Use Bun's official image
|
# Use Node.js official image
|
||||||
FROM oven/bun:1 AS base
|
FROM node:20-alpine AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm
|
||||||
|
RUN npm install -g pnpm
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json bun.lock* ./
|
COPY package.json pnpm-lock.yaml* ./
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
RUN bun install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN bunx patch-package
|
RUN pnpm patch-package
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
@@ -15,7 +18,7 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build the SvelteKit app
|
# Build the SvelteKit app
|
||||||
RUN bun run build
|
RUN pnpm run build
|
||||||
|
|
||||||
# Production image
|
# Production image
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
@@ -32,10 +35,10 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
# Copy Drizzle files for migrations
|
# Copy Drizzle files for migrations
|
||||||
COPY --from=builder /app/drizzle ./drizzle
|
COPY --from=builder /app/drizzle ./drizzle
|
||||||
COPY --from=builder /app/drizzle.config.ts ./
|
COPY --from=builder /app/drizzle.config.ts ./
|
||||||
COPY --from=builder /app/src/lib/server/schema.ts ./src/lib/server/schema.ts
|
COPY --from=builder /app/src/lib/db ./src/lib/db
|
||||||
|
|
||||||
# Expose the port
|
# Expose the port
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start the application
|
# Start the application
|
||||||
CMD ["bun", "run", "./build/index.js"]
|
CMD ["node", "./build/index.js"]
|
||||||
|
|||||||
5
SETUP.md
5
SETUP.md
@@ -16,6 +16,7 @@ Visit `http://localhost:3000`
|
|||||||
Choose one:
|
Choose one:
|
||||||
|
|
||||||
**Local PostgreSQL:**
|
**Local PostgreSQL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo apt install postgresql
|
sudo apt install postgresql
|
||||||
sudo -u postgres createdb wishlist
|
sudo -u postgres createdb wishlist
|
||||||
@@ -26,6 +27,7 @@ GRANT ALL PRIVILEGES ON DATABASE wishlist TO wishlistuser;
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Docker PostgreSQL:**
|
**Docker PostgreSQL:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --name wishlist-postgres \
|
docker run --name wishlist-postgres \
|
||||||
-e POSTGRES_DB=wishlist \
|
-e POSTGRES_DB=wishlist \
|
||||||
@@ -57,15 +59,18 @@ Visit `http://localhost:5173`
|
|||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
**Connection errors:**
|
**Connection errors:**
|
||||||
|
|
||||||
- Check PostgreSQL is running: `sudo systemctl status postgresql`
|
- Check PostgreSQL is running: `sudo systemctl status postgresql`
|
||||||
- Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"`
|
- Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"`
|
||||||
|
|
||||||
**Port in use:**
|
**Port in use:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run dev -- --port 3000
|
bun run dev -- --port 3000
|
||||||
```
|
```
|
||||||
|
|
||||||
**Schema changes:**
|
**Schema changes:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run db:push
|
bun run db:push
|
||||||
```
|
```
|
||||||
|
|||||||
632
bun.lock
632
bun.lock
@@ -1,632 +0,0 @@
|
|||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "wishlist-app",
|
|
||||||
"dependencies": {
|
|
||||||
"@auth/core": "^0.34.3",
|
|
||||||
"@auth/drizzle-adapter": "^1.11.1",
|
|
||||||
"@auth/sveltekit": "^1.11.1",
|
|
||||||
"@internationalized/date": "^3.10.0",
|
|
||||||
"@paralleldrive/cuid2": "^3.0.4",
|
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"bits-ui": "^2.14.4",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"drizzle-orm": "^0.44.7",
|
|
||||||
"lucide-svelte": "^0.554.0",
|
|
||||||
"postgres": "^3.4.7",
|
|
||||||
"svelte-dnd-action": "^0.9.67",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"tailwind-variants": "^3.2.2",
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@lucide/svelte": "^0.544.0",
|
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
|
||||||
"@sveltejs/kit": "^2.48.5",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"drizzle-kit": "^0.31.7",
|
|
||||||
"patch-package": "^8.0.1",
|
|
||||||
"postinstall-postinstall": "^2.1.0",
|
|
||||||
"svelte": "^5.43.8",
|
|
||||||
"svelte-check": "^4.3.4",
|
|
||||||
"tailwindcss": "^4.1.17",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.2.2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@auth/core": ["@auth/core@0.34.3", "", { "dependencies": { "@panva/hkdf": "^1.1.1", "@types/cookie": "0.6.0", "cookie": "0.6.0", "jose": "^5.1.3", "oauth4webapi": "^2.10.4", "preact": "10.11.3", "preact-render-to-string": "5.2.3" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw=="],
|
|
||||||
|
|
||||||
"@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.11.1", "", { "dependencies": { "@auth/core": "0.41.1" } }, "sha512-cQTvDZqsyF7RPhDm/B6SvqdVP9EzQhy3oM4Muu7fjjmSYFLbSR203E6dH631ZHSKDn2b4WZkfMnjPDzRsPSAeA=="],
|
|
||||||
|
|
||||||
"@auth/sveltekit": ["@auth/sveltekit@1.11.1", "", { "dependencies": { "@auth/core": "0.41.1", "set-cookie-parser": "^2.7.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.3", "@sveltejs/kit": "^1.0.0 || ^2.0.0", "nodemailer": "^7.0.7", "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-cWNfXcKrNIVtJYOY1tq7H7m03j89Wg7xrTvOJALu18fZdYulzYCPIAdTw8XSEzOp6KyhOGo7tmW7VtzRNtr/8Q=="],
|
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
|
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
|
||||||
|
|
||||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
|
||||||
|
|
||||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
|
||||||
|
|
||||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
|
||||||
|
|
||||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
|
||||||
|
|
||||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
|
||||||
|
|
||||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
|
||||||
|
|
||||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
|
||||||
|
|
||||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
|
||||||
|
|
||||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
|
||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
|
||||||
|
|
||||||
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
|
|
||||||
|
|
||||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
|
|
||||||
|
|
||||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
|
|
||||||
|
|
||||||
"@internationalized/date": ["@internationalized/date@3.10.0", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw=="],
|
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
|
||||||
|
|
||||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
|
||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
|
||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
|
||||||
|
|
||||||
"@lucide/svelte": ["@lucide/svelte@0.544.0", "", { "peerDependencies": { "svelte": "^5" } }, "sha512-9f9O6uxng2pLB01sxNySHduJN3HTl5p0HDu4H26VR51vhZfiMzyOMe9Mhof3XAk4l813eTtl+/DYRvGyoRR+yw=="],
|
|
||||||
|
|
||||||
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
|
||||||
|
|
||||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
|
||||||
|
|
||||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@3.0.4", "", { "dependencies": { "@noble/hashes": "^2.0.1", "bignumber.js": "^9.3.1", "error-causes": "^3.0.2" }, "bin": { "cuid2": "bin/cuid2.js" } }, "sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg=="],
|
|
||||||
|
|
||||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
|
||||||
|
|
||||||
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@28.0.9", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA=="],
|
|
||||||
|
|
||||||
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
|
||||||
|
|
||||||
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
|
|
||||||
|
|
||||||
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.3", "", { "os": "android", "cpu": "arm" }, "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.3", "", { "os": "android", "cpu": "arm64" }, "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.53.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.53.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.53.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.53.3", "", { "os": "linux", "cpu": "arm" }, "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.53.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.53.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.53.3", "", { "os": "linux", "cpu": "none" }, "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.53.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.53.3", "", { "os": "linux", "cpu": "x64" }, "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.53.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.53.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.53.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg=="],
|
|
||||||
|
|
||||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.53.3", "", { "os": "win32", "cpu": "x64" }, "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ=="],
|
|
||||||
|
|
||||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
|
|
||||||
|
|
||||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.7", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-znp1A/Y1Jj4l/Zy7PX5DZKBE0ZNY+5QBngiE21NJkfSTyzzC5iKNWOtwFXKtIrn7MXEFBck4jD95iBNkGjK92Q=="],
|
|
||||||
|
|
||||||
"@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="],
|
|
||||||
|
|
||||||
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.4.0", "", { "dependencies": { "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ=="],
|
|
||||||
|
|
||||||
"@sveltejs/kit": ["@sveltejs/kit@2.49.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-oH8tXw7EZnie8FdOWYrF7Yn4IKrqTFHhXvl8YxXxbKwTMcD/5NNCryUSEXRk2ZR4ojnub0P8rNrsVGHXWqIDtA=="],
|
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.1", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", "deepmerge": "^4.3.1", "magic-string": "^0.30.17", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ=="],
|
|
||||||
|
|
||||||
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
|
|
||||||
|
|
||||||
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
|
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.17", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.17" } }, "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.17", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.17", "@tailwindcss/oxide-darwin-arm64": "4.1.17", "@tailwindcss/oxide-darwin-x64": "4.1.17", "@tailwindcss/oxide-freebsd-x64": "4.1.17", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", "@tailwindcss/oxide-linux-x64-musl": "4.1.17", "@tailwindcss/oxide-wasm32-wasi": "4.1.17", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" } }, "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.17", "", { "os": "android", "cpu": "arm64" }, "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.17", "", { "os": "linux", "cpu": "arm" }, "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.17", "", { "os": "linux", "cpu": "x64" }, "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.17", "", { "dependencies": { "@emnapi/core": "^1.6.0", "@emnapi/runtime": "^1.6.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.7", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.17", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.17", "", { "dependencies": { "@tailwindcss/node": "4.1.17", "@tailwindcss/oxide": "4.1.17", "tailwindcss": "4.1.17" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA=="],
|
|
||||||
|
|
||||||
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
|
|
||||||
|
|
||||||
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
|
||||||
|
|
||||||
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
|
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
|
||||||
|
|
||||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
|
||||||
|
|
||||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
|
||||||
|
|
||||||
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
|
||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.14.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg=="],
|
|
||||||
|
|
||||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
|
||||||
|
|
||||||
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
|
|
||||||
|
|
||||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
|
||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
|
||||||
|
|
||||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
|
||||||
|
|
||||||
"ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="],
|
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
|
||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
|
||||||
|
|
||||||
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
|
||||||
|
|
||||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
|
||||||
|
|
||||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
|
||||||
|
|
||||||
"devalue": ["devalue@5.5.0", "", {}, "sha512-69sM5yrHfFLJt0AZ9QqZXGCPfJ7fQjvpln3Rq5+PS03LD32Ost1Q9N+eEnaQwGRIriKkMImXD56ocjQmfjbV3w=="],
|
|
||||||
|
|
||||||
"drizzle-kit": ["drizzle-kit@0.31.7", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-hOzRGSdyKIU4FcTSFYGKdXEjFsncVwHZ43gY3WU5Bz9j5Iadp6Rh6hxLSQ1IWXpKLBKt/d5y1cpSPcV+FcoQ1A=="],
|
|
||||||
|
|
||||||
"drizzle-orm": ["drizzle-orm@0.44.7", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-quIpnYznjU9lHshEOAYLoZ9s3jweleHlZIAWR/jX9gAWNg/JhQ1wj0KGRf7/Zm+obRrYd9GjPVJg790QY9N5AQ=="],
|
|
||||||
|
|
||||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
|
||||||
|
|
||||||
"error-causes": ["error-causes@3.0.2", "", {}, "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw=="],
|
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
|
||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
|
||||||
|
|
||||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
|
||||||
|
|
||||||
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
|
||||||
|
|
||||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
|
||||||
|
|
||||||
"esrap": ["esrap@2.1.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-T/Dhhv/QH+yYmiaLz9SA3PW+YyenlnRKDNdtlYJrSOBmNsH4nvPux+mTwx7p+wAedlJrGoZtXNI0a0MjQ2QkVg=="],
|
|
||||||
|
|
||||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
|
||||||
|
|
||||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
|
||||||
|
|
||||||
"find-yarn-workspace-root": ["find-yarn-workspace-root@2.0.0", "", { "dependencies": { "micromatch": "^4.0.2" } }, "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ=="],
|
|
||||||
|
|
||||||
"fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="],
|
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
|
||||||
|
|
||||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
|
||||||
|
|
||||||
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
|
|
||||||
|
|
||||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
|
||||||
|
|
||||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
|
||||||
|
|
||||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
|
||||||
|
|
||||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
|
||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
|
||||||
|
|
||||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
|
||||||
|
|
||||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
|
||||||
|
|
||||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
|
||||||
|
|
||||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
|
||||||
|
|
||||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
|
||||||
|
|
||||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
|
||||||
|
|
||||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
|
||||||
|
|
||||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
|
||||||
|
|
||||||
"jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="],
|
|
||||||
|
|
||||||
"json-stable-stringify": ["json-stable-stringify@1.3.0", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "isarray": "^2.0.5", "jsonify": "^0.0.1", "object-keys": "^1.1.1" } }, "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg=="],
|
|
||||||
|
|
||||||
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
|
|
||||||
|
|
||||||
"jsonify": ["jsonify@0.0.1", "", {}, "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg=="],
|
|
||||||
|
|
||||||
"klaw-sync": ["klaw-sync@6.0.0", "", { "dependencies": { "graceful-fs": "^4.1.11" } }, "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ=="],
|
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
|
||||||
|
|
||||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
|
||||||
|
|
||||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
|
||||||
|
|
||||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
|
||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
|
||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
|
||||||
|
|
||||||
"lucide-svelte": ["lucide-svelte@0.554.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-LLcpHi3SuKup0nVD1kKqo8FDZnjXJp48uST26GGh8Jcyrxqk5gmgpnvKmHsHox674UL3cPS1DCul/wFL7ybGqg=="],
|
|
||||||
|
|
||||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
|
||||||
|
|
||||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
|
||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@8.5.0", "", {}, "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A=="],
|
|
||||||
|
|
||||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
|
||||||
|
|
||||||
"oauth4webapi": ["oauth4webapi@2.17.0", "", {}, "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ=="],
|
|
||||||
|
|
||||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
|
||||||
|
|
||||||
"open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
|
|
||||||
|
|
||||||
"patch-package": ["patch-package@8.0.1", "", { "dependencies": { "@yarnpkg/lockfile": "^1.1.0", "chalk": "^4.1.2", "ci-info": "^3.7.0", "cross-spawn": "^7.0.3", "find-yarn-workspace-root": "^2.0.0", "fs-extra": "^10.0.0", "json-stable-stringify": "^1.0.2", "klaw-sync": "^6.0.0", "minimist": "^1.2.6", "open": "^7.4.2", "semver": "^7.5.3", "slash": "^2.0.0", "tmp": "^0.2.4", "yaml": "^2.2.2" }, "bin": { "patch-package": "index.js" } }, "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw=="],
|
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
|
||||||
|
|
||||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
|
||||||
|
|
||||||
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
|
|
||||||
|
|
||||||
"preact": ["preact@10.11.3", "", {}, "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg=="],
|
|
||||||
|
|
||||||
"preact-render-to-string": ["preact-render-to-string@5.2.3", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA=="],
|
|
||||||
|
|
||||||
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="],
|
|
||||||
|
|
||||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
|
||||||
|
|
||||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
|
||||||
|
|
||||||
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
|
|
||||||
|
|
||||||
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
|
||||||
|
|
||||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
|
||||||
|
|
||||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
|
||||||
|
|
||||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
|
||||||
|
|
||||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
|
||||||
|
|
||||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
|
||||||
|
|
||||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
|
||||||
|
|
||||||
"slash": ["slash@2.0.0", "", {}, "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A=="],
|
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
|
||||||
|
|
||||||
"source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="],
|
|
||||||
|
|
||||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
|
||||||
|
|
||||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
|
||||||
|
|
||||||
"svelte": ["svelte@5.43.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-pHeUrp1A5S6RGaXhJB7PtYjL1VVjbVrJ2EfuAoPu9/1LeoMaJa/pcdCsCSb0gS4eUHAHnhCbUDxORZyvGK6kOQ=="],
|
|
||||||
|
|
||||||
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
|
|
||||||
|
|
||||||
"svelte-dnd-action": ["svelte-dnd-action@0.9.67", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-yEJQZ9SFy3O4mnOdtjwWyotRsWRktNf4W8k67zgiLiMtMNQnwCyJHBjkGMgZMDh8EGZ4gr88l+GebBWoHDwo+g=="],
|
|
||||||
|
|
||||||
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="],
|
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
|
||||||
|
|
||||||
"tailwind-variants": ["tailwind-variants@3.2.2", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg=="],
|
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
|
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
|
||||||
|
|
||||||
"tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="],
|
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
|
||||||
|
|
||||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
|
||||||
|
|
||||||
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
|
|
||||||
|
|
||||||
"vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="],
|
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
|
||||||
|
|
||||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
|
||||||
|
|
||||||
"yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="],
|
|
||||||
|
|
||||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
|
||||||
|
|
||||||
"@auth/drizzle-adapter/@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
|
|
||||||
|
|
||||||
"@auth/sveltekit/@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
|
||||||
|
|
||||||
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
|
||||||
|
|
||||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
|
||||||
|
|
||||||
"@auth/drizzle-adapter/@auth/core/jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="],
|
|
||||||
|
|
||||||
"@auth/drizzle-adapter/@auth/core/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
|
|
||||||
|
|
||||||
"@auth/drizzle-adapter/@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
|
||||||
|
|
||||||
"@auth/drizzle-adapter/@auth/core/preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
|
||||||
|
|
||||||
"@auth/sveltekit/@auth/core/jose": ["jose@6.1.2", "", {}, "sha512-MpcPtHLE5EmztuFIqB0vzHAWJPpmN1E6L4oo+kze56LIs3MyXIj9ZHMDxqOvkP38gBR7K1v3jqd4WU2+nrfONQ=="],
|
|
||||||
|
|
||||||
"@auth/sveltekit/@auth/core/oauth4webapi": ["oauth4webapi@3.8.3", "", {}, "sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw=="],
|
|
||||||
|
|
||||||
"@auth/sveltekit/@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
|
||||||
|
|
||||||
"@auth/sveltekit/@auth/core/preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
|
|
||||||
|
|
||||||
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
"tailwind": {
|
"tailwind": {
|
||||||
"css": "src/app.css",
|
"css": "src/app.css",
|
||||||
"baseColor": "slate"
|
"baseColor": "slate"
|
||||||
},
|
},
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"components": "$lib/components",
|
"components": "$lib/components",
|
||||||
"utils": "$lib/utils",
|
"utils": "$lib/utils",
|
||||||
"ui": "$lib/components/ui",
|
"ui": "$lib/components/ui",
|
||||||
"hooks": "$lib/hooks",
|
"hooks": "$lib/hooks",
|
||||||
"lib": "$lib"
|
"lib": "$lib"
|
||||||
},
|
},
|
||||||
"typescript": true,
|
"typescript": true,
|
||||||
"registry": "https://shadcn-svelte.com/registry"
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- db-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data
|
- /mnt/HC_Volume_102830676/wishlist:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
PORT: 3000
|
PORT: 3000
|
||||||
AUTH_SECRET: ${AUTH_SECRET}
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
AUTH_URL: ${AUTH_URL}
|
AUTH_URL: ${AUTH_URL}
|
||||||
AUTH_TRUST_HOST: "true"
|
AUTH_TRUST_HOST: 'true'
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { Config } from 'drizzle-kit';
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: './src/lib/server/schema.ts',
|
schema: './src/lib/db/schema.ts',
|
||||||
out: './drizzle',
|
out: './drizzle',
|
||||||
dialect: 'postgresql',
|
dialect: 'postgresql',
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL || ''
|
url: process.env.DATABASE_URL || ''
|
||||||
}
|
}
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
import { relations } from "drizzle-orm/relations";
|
import { relations } from 'drizzle-orm/relations';
|
||||||
import { wishlists, items, user, savedWishlists, reservations, session, account } from "./schema";
|
import { wishlists, items, user, savedWishlists, reservations, session, account } from './schema';
|
||||||
|
|
||||||
export const itemsRelations = relations(items, ({one, many}) => ({
|
export const itemsRelations = relations(items, ({ one, many }) => ({
|
||||||
wishlist: one(wishlists, {
|
wishlist: one(wishlists, {
|
||||||
fields: [items.wishlistId],
|
fields: [items.wishlistId],
|
||||||
references: [wishlists.id]
|
references: [wishlists.id]
|
||||||
}),
|
}),
|
||||||
reservations: many(reservations),
|
reservations: many(reservations)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const wishlistsRelations = relations(wishlists, ({one, many}) => ({
|
export const wishlistsRelations = relations(wishlists, ({ one, many }) => ({
|
||||||
items: many(items),
|
items: many(items),
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [wishlists.userId],
|
fields: [wishlists.userId],
|
||||||
references: [user.id]
|
references: [user.id]
|
||||||
}),
|
}),
|
||||||
savedWishlists: many(savedWishlists),
|
savedWishlists: many(savedWishlists)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const userRelations = relations(user, ({many}) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
wishlists: many(wishlists),
|
wishlists: many(wishlists),
|
||||||
savedWishlists: many(savedWishlists),
|
savedWishlists: many(savedWishlists),
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const savedWishlistsRelations = relations(savedWishlists, ({one}) => ({
|
export const savedWishlistsRelations = relations(savedWishlists, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [savedWishlists.userId],
|
fields: [savedWishlists.userId],
|
||||||
references: [user.id]
|
references: [user.id]
|
||||||
}),
|
}),
|
||||||
wishlist: one(wishlists, {
|
wishlist: one(wishlists, {
|
||||||
fields: [savedWishlists.wishlistId],
|
fields: [savedWishlists.wishlistId],
|
||||||
references: [wishlists.id]
|
references: [wishlists.id]
|
||||||
}),
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const reservationsRelations = relations(reservations, ({one}) => ({
|
export const reservationsRelations = relations(reservations, ({ one }) => ({
|
||||||
item: one(items, {
|
item: one(items, {
|
||||||
fields: [reservations.itemId],
|
fields: [reservations.itemId],
|
||||||
references: [items.id]
|
references: [items.id]
|
||||||
}),
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({one}) => ({
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [session.userId],
|
fields: [session.userId],
|
||||||
references: [user.id]
|
references: [user.id]
|
||||||
}),
|
})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({one}) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [account.userId],
|
fields: [account.userId],
|
||||||
references: [user.id]
|
references: [user.id]
|
||||||
}),
|
})
|
||||||
}));
|
}));
|
||||||
@@ -1,141 +1,185 @@
|
|||||||
import { pgTable, foreignKey, text, numeric, boolean, timestamp, unique, primaryKey } from "drizzle-orm/pg-core"
|
import {
|
||||||
import { sql } from "drizzle-orm"
|
pgTable,
|
||||||
|
foreignKey,
|
||||||
|
text,
|
||||||
|
numeric,
|
||||||
|
boolean,
|
||||||
|
timestamp,
|
||||||
|
unique,
|
||||||
|
primaryKey
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const items = pgTable(
|
||||||
|
'items',
|
||||||
|
{
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
wishlistId: text('wishlist_id').notNull(),
|
||||||
|
title: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
link: text(),
|
||||||
|
imageUrl: text('image_url'),
|
||||||
|
price: numeric({ precision: 10, scale: 2 }),
|
||||||
|
currency: text().default('DKK'),
|
||||||
|
color: text(),
|
||||||
|
order: numeric().default('0').notNull(),
|
||||||
|
isReserved: boolean('is_reserved').default(false).notNull(),
|
||||||
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull()
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.wishlistId],
|
||||||
|
foreignColumns: [wishlists.id],
|
||||||
|
name: 'items_wishlist_id_wishlists_id_fk'
|
||||||
|
}).onDelete('cascade')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const wishlists = pgTable(
|
||||||
|
'wishlists',
|
||||||
|
{
|
||||||
|
id: text().primaryKey().notNull(),
|
||||||
|
userId: text('user_id'),
|
||||||
|
title: text().notNull(),
|
||||||
|
description: text(),
|
||||||
|
ownerToken: text('owner_token').notNull(),
|
||||||
|
publicToken: text('public_token').notNull(),
|
||||||
|
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||||
|
color: text(),
|
||||||
|
endDate: timestamp('end_date', { mode: 'string' }),
|
||||||
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
|
theme: text().default('none')
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [user.id],
|
||||||
|
name: 'wishlists_user_id_user_id_fk'
|
||||||
|
}).onDelete('set null'),
|
||||||
|
unique('wishlists_owner_token_unique').on(table.ownerToken),
|
||||||
|
unique('wishlists_public_token_unique').on(table.publicToken)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const items = pgTable("items", {
|
export const savedWishlists = pgTable(
|
||||||
id: text().primaryKey().notNull(),
|
'saved_wishlists',
|
||||||
wishlistId: text("wishlist_id").notNull(),
|
{
|
||||||
title: text().notNull(),
|
id: text().primaryKey().notNull(),
|
||||||
description: text(),
|
userId: text('user_id').notNull(),
|
||||||
link: text(),
|
wishlistId: text('wishlist_id').notNull(),
|
||||||
imageUrl: text("image_url"),
|
isFavorite: boolean('is_favorite').default(false).notNull(),
|
||||||
price: numeric({ precision: 10, scale: 2 }),
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
currency: text().default('DKK'),
|
ownerToken: text('owner_token')
|
||||||
color: text(),
|
},
|
||||||
order: numeric().default('0').notNull(),
|
(table) => [
|
||||||
isReserved: boolean("is_reserved").default(false).notNull(),
|
foreignKey({
|
||||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
columns: [table.userId],
|
||||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
foreignColumns: [user.id],
|
||||||
}, (table) => [
|
name: 'saved_wishlists_user_id_user_id_fk'
|
||||||
foreignKey({
|
}).onDelete('cascade'),
|
||||||
columns: [table.wishlistId],
|
foreignKey({
|
||||||
foreignColumns: [wishlists.id],
|
columns: [table.wishlistId],
|
||||||
name: "items_wishlist_id_wishlists_id_fk"
|
foreignColumns: [wishlists.id],
|
||||||
}).onDelete("cascade"),
|
name: 'saved_wishlists_wishlist_id_wishlists_id_fk'
|
||||||
]);
|
}).onDelete('cascade')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const wishlists = pgTable("wishlists", {
|
export const user = pgTable(
|
||||||
id: text().primaryKey().notNull(),
|
'user',
|
||||||
userId: text("user_id"),
|
{
|
||||||
title: text().notNull(),
|
id: text().primaryKey().notNull(),
|
||||||
description: text(),
|
name: text(),
|
||||||
ownerToken: text("owner_token").notNull(),
|
email: text(),
|
||||||
publicToken: text("public_token").notNull(),
|
emailVerified: timestamp({ mode: 'string' }),
|
||||||
isFavorite: boolean("is_favorite").default(false).notNull(),
|
image: text(),
|
||||||
color: text(),
|
password: text(),
|
||||||
endDate: timestamp("end_date", { mode: 'string' }),
|
username: text(),
|
||||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
dashboardTheme: text('dashboard_theme').default('none'),
|
||||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
dashboardColor: text('dashboard_color'),
|
||||||
theme: text().default('none'),
|
lastLogin: timestamp('last_login', { mode: 'string' }),
|
||||||
}, (table) => [
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull(),
|
||||||
foreignKey({
|
updatedAt: timestamp('updated_at', { mode: 'string' }).defaultNow().notNull()
|
||||||
columns: [table.userId],
|
},
|
||||||
foreignColumns: [user.id],
|
(table) => [
|
||||||
name: "wishlists_user_id_user_id_fk"
|
unique('user_email_unique').on(table.email),
|
||||||
}).onDelete("set null"),
|
unique('user_username_unique').on(table.username)
|
||||||
unique("wishlists_owner_token_unique").on(table.ownerToken),
|
]
|
||||||
unique("wishlists_public_token_unique").on(table.publicToken),
|
);
|
||||||
]);
|
|
||||||
|
|
||||||
export const savedWishlists = pgTable("saved_wishlists", {
|
export const reservations = pgTable(
|
||||||
id: text().primaryKey().notNull(),
|
'reservations',
|
||||||
userId: text("user_id").notNull(),
|
{
|
||||||
wishlistId: text("wishlist_id").notNull(),
|
id: text().primaryKey().notNull(),
|
||||||
isFavorite: boolean("is_favorite").default(false).notNull(),
|
itemId: text('item_id').notNull(),
|
||||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
reserverName: text('reserver_name'),
|
||||||
ownerToken: text("owner_token"),
|
createdAt: timestamp('created_at', { mode: 'string' }).defaultNow().notNull()
|
||||||
}, (table) => [
|
},
|
||||||
foreignKey({
|
(table) => [
|
||||||
columns: [table.userId],
|
foreignKey({
|
||||||
foreignColumns: [user.id],
|
columns: [table.itemId],
|
||||||
name: "saved_wishlists_user_id_user_id_fk"
|
foreignColumns: [items.id],
|
||||||
}).onDelete("cascade"),
|
name: 'reservations_item_id_items_id_fk'
|
||||||
foreignKey({
|
}).onDelete('cascade')
|
||||||
columns: [table.wishlistId],
|
]
|
||||||
foreignColumns: [wishlists.id],
|
);
|
||||||
name: "saved_wishlists_wishlist_id_wishlists_id_fk"
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const session = pgTable(
|
||||||
id: text().primaryKey().notNull(),
|
'session',
|
||||||
name: text(),
|
{
|
||||||
email: text(),
|
sessionToken: text().primaryKey().notNull(),
|
||||||
emailVerified: timestamp({ mode: 'string' }),
|
userId: text().notNull(),
|
||||||
image: text(),
|
expires: timestamp({ mode: 'string' }).notNull()
|
||||||
password: text(),
|
},
|
||||||
username: text(),
|
(table) => [
|
||||||
dashboardTheme: text("dashboard_theme").default('none'),
|
foreignKey({
|
||||||
dashboardColor: text("dashboard_color"),
|
columns: [table.userId],
|
||||||
lastLogin: timestamp("last_login", { mode: 'string' }),
|
foreignColumns: [user.id],
|
||||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
name: 'session_userId_user_id_fk'
|
||||||
updatedAt: timestamp("updated_at", { mode: 'string' }).defaultNow().notNull(),
|
}).onDelete('cascade')
|
||||||
}, (table) => [
|
]
|
||||||
unique("user_email_unique").on(table.email),
|
);
|
||||||
unique("user_username_unique").on(table.username),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const reservations = pgTable("reservations", {
|
export const verificationToken = pgTable(
|
||||||
id: text().primaryKey().notNull(),
|
'verificationToken',
|
||||||
itemId: text("item_id").notNull(),
|
{
|
||||||
reserverName: text("reserver_name"),
|
identifier: text().notNull(),
|
||||||
createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(),
|
token: text().notNull(),
|
||||||
}, (table) => [
|
expires: timestamp({ mode: 'string' }).notNull()
|
||||||
foreignKey({
|
},
|
||||||
columns: [table.itemId],
|
(table) => [
|
||||||
foreignColumns: [items.id],
|
primaryKey({
|
||||||
name: "reservations_item_id_items_id_fk"
|
columns: [table.identifier, table.token],
|
||||||
}).onDelete("cascade"),
|
name: 'verificationToken_identifier_token_pk'
|
||||||
]);
|
})
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
export const session = pgTable("session", {
|
export const account = pgTable(
|
||||||
sessionToken: text().primaryKey().notNull(),
|
'account',
|
||||||
userId: text().notNull(),
|
{
|
||||||
expires: timestamp({ mode: 'string' }).notNull(),
|
userId: text().notNull(),
|
||||||
}, (table) => [
|
type: text().notNull(),
|
||||||
foreignKey({
|
provider: text().notNull(),
|
||||||
columns: [table.userId],
|
providerAccountId: text().notNull(),
|
||||||
foreignColumns: [user.id],
|
refreshToken: text('refresh_token'),
|
||||||
name: "session_userId_user_id_fk"
|
accessToken: text('access_token'),
|
||||||
}).onDelete("cascade"),
|
expiresAt: numeric('expires_at'),
|
||||||
]);
|
tokenType: text('token_type'),
|
||||||
|
scope: text(),
|
||||||
export const verificationToken = pgTable("verificationToken", {
|
idToken: text('id_token'),
|
||||||
identifier: text().notNull(),
|
sessionState: text('session_state')
|
||||||
token: text().notNull(),
|
},
|
||||||
expires: timestamp({ mode: 'string' }).notNull(),
|
(table) => [
|
||||||
}, (table) => [
|
foreignKey({
|
||||||
primaryKey({ columns: [table.identifier, table.token], name: "verificationToken_identifier_token_pk"}),
|
columns: [table.userId],
|
||||||
]);
|
foreignColumns: [user.id],
|
||||||
|
name: 'account_userId_user_id_fk'
|
||||||
export const account = pgTable("account", {
|
}).onDelete('cascade'),
|
||||||
userId: text().notNull(),
|
primaryKey({
|
||||||
type: text().notNull(),
|
columns: [table.provider, table.providerAccountId],
|
||||||
provider: text().notNull(),
|
name: 'account_provider_providerAccountId_pk'
|
||||||
providerAccountId: text().notNull(),
|
})
|
||||||
refreshToken: text("refresh_token"),
|
]
|
||||||
accessToken: text("access_token"),
|
);
|
||||||
expiresAt: numeric("expires_at"),
|
|
||||||
tokenType: text("token_type"),
|
|
||||||
scope: text(),
|
|
||||||
idToken: text("id_token"),
|
|
||||||
sessionState: text("session_state"),
|
|
||||||
}, (table) => [
|
|
||||||
foreignKey({
|
|
||||||
columns: [table.userId],
|
|
||||||
foreignColumns: [user.id],
|
|
||||||
name: "account_userId_user_id_fk"
|
|
||||||
}).onDelete("cascade"),
|
|
||||||
primaryKey({ columns: [table.provider, table.providerAccountId], name: "account_provider_providerAccountId_pk"}),
|
|
||||||
]);
|
|
||||||
|
|||||||
47
eslint.config.js
Normal file
47
eslint.config.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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/']
|
||||||
|
}
|
||||||
|
];
|
||||||
112
package.json
112
package.json
@@ -1,53 +1,63 @@
|
|||||||
{
|
{
|
||||||
"name": "wishlist-app",
|
"name": "wishlist-app",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio",
|
||||||
},
|
"lint": "prettier --check . && eslint .",
|
||||||
"devDependencies": {
|
"format": "prettier --write ."
|
||||||
"@lucide/svelte": "^0.544.0",
|
},
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@eslint/js": "^9.25.0",
|
||||||
"@sveltejs/kit": "^2.48.5",
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@sveltejs/adapter-node": "^5.4.0",
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@sveltejs/kit": "^2.48.5",
|
||||||
"drizzle-kit": "^0.31.7",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"patch-package": "^8.0.1",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"svelte": "^5.43.8",
|
"drizzle-kit": "^0.31.7",
|
||||||
"svelte-check": "^4.3.4",
|
"eslint": "^9.25.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"eslint-plugin-svelte": "^3.5.1",
|
||||||
"tw-animate-css": "^1.4.0",
|
"globals": "^16.0.0",
|
||||||
"typescript": "^5.9.3",
|
"patch-package": "^8.0.1",
|
||||||
"vite": "^7.2.2"
|
"postinstall-postinstall": "^2.1.0",
|
||||||
},
|
"prettier": "^3.5.3",
|
||||||
"dependencies": {
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"@auth/core": "^0.34.3",
|
"svelte": "^5.43.8",
|
||||||
"@auth/drizzle-adapter": "^1.11.1",
|
"svelte-check": "^4.3.4",
|
||||||
"@auth/sveltekit": "^1.11.1",
|
"svelte-eslint-parser": "^1.6.0",
|
||||||
"@internationalized/date": "^3.10.0",
|
"tailwindcss": "^4.1.17",
|
||||||
"@paralleldrive/cuid2": "^3.0.4",
|
"tw-animate-css": "^1.4.0",
|
||||||
"bcrypt": "^6.0.0",
|
"typescript": "^5.9.3",
|
||||||
"bits-ui": "^2.14.4",
|
"typescript-eslint": "^8.31.0",
|
||||||
"clsx": "^2.1.1",
|
"vite": "^7.2.2"
|
||||||
"drizzle-orm": "^0.44.7",
|
},
|
||||||
"lucide-svelte": "^0.554.0",
|
"dependencies": {
|
||||||
"postgres": "^3.4.7",
|
"@auth/core": "^0.34.3",
|
||||||
"svelte-dnd-action": "^0.9.67",
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"@auth/sveltekit": "^1.11.1",
|
||||||
"tailwind-variants": "^3.2.2"
|
"@internationalized/date": "^3.10.0",
|
||||||
}
|
"@paralleldrive/cuid2": "^3.0.4",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"bits-ui": "^2.14.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
|
"svelte-dnd-action": "^0.9.67",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"tailwind-variants": "^3.2.2",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3975
pnpm-lock.yaml
generated
Normal file
3975
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
src/app.css
14
src/app.css
@@ -1,15 +1,15 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
* {
|
* {
|
||||||
transition:
|
transition:
|
||||||
background-color 1s ease,
|
background-color 1s ease,
|
||||||
background-image 1s ease,
|
background-image 1s ease,
|
||||||
color 1s ease,
|
color 1s ease,
|
||||||
border-color 1s ease;
|
border-color 1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
16
src/app.d.ts
vendored
16
src/app.d.ts
vendored
@@ -1,14 +1,14 @@
|
|||||||
import type { Session } from '@auth/core/types';
|
import type { Session } from '@auth/core/types';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
interface PageData {
|
interface PageData {
|
||||||
session: Session | null;
|
session: Session | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
37
src/app.html
37
src/app.html
@@ -1,21 +1,22 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function () {
|
||||||
const theme = localStorage.getItem('theme') || 'system';
|
const theme = localStorage.getItem('theme') || 'system';
|
||||||
const isDark = theme === 'dark' ||
|
const isDark =
|
||||||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
theme === 'dark' ||
|
||||||
if (isDark) {
|
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
document.documentElement.classList.add('dark');
|
if (isDark) {
|
||||||
}
|
document.documentElement.classList.add('dark');
|
||||||
})();
|
}
|
||||||
</script>
|
})();
|
||||||
%sveltekit.head%
|
</script>
|
||||||
</head>
|
%sveltekit.head%
|
||||||
<body data-sveltekit-preload-data="hover">
|
</head>
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<body data-sveltekit-preload-data="hover">
|
||||||
</body>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
219
src/auth.ts
219
src/auth.ts
@@ -4,128 +4,131 @@ 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/server/schema';
|
import { users } from '$lib/db/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<any> {
|
}): OAuthConfig<AuthentikProfile> {
|
||||||
return {
|
return {
|
||||||
id: 'authentik',
|
id: 'authentik',
|
||||||
name: 'Authentik',
|
name: 'Authentik',
|
||||||
type: 'oidc',
|
type: 'oidc',
|
||||||
clientId: config.clientId,
|
clientId: config.clientId,
|
||||||
clientSecret: config.clientSecret,
|
clientSecret: config.clientSecret,
|
||||||
issuer: config.issuer,
|
issuer: config.issuer,
|
||||||
authorization: {
|
authorization: {
|
||||||
params: {
|
params: {
|
||||||
scope: 'openid email profile'
|
scope: 'openid email profile'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
profile(profile) {
|
profile(profile) {
|
||||||
return {
|
return {
|
||||||
id: profile.sub,
|
id: profile.sub,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
name: profile.name || profile.preferred_username,
|
name: profile.name || profile.preferred_username,
|
||||||
image: profile.picture
|
image: profile.picture
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const authConfig: SvelteKitAuthConfig = {
|
const authConfig: SvelteKitAuthConfig = {
|
||||||
adapter: DrizzleAdapter(db),
|
adapter: DrizzleAdapter(db),
|
||||||
session: {
|
session: {
|
||||||
strategy: 'jwt'
|
strategy: 'jwt'
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||||
? [
|
? [
|
||||||
Google({
|
Google({
|
||||||
clientId: env.GOOGLE_CLIENT_ID,
|
clientId: env.GOOGLE_CLIENT_ID,
|
||||||
clientSecret: env.GOOGLE_CLIENT_SECRET
|
clientSecret: env.GOOGLE_CLIENT_SECRET
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
|
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
|
||||||
? [
|
? [
|
||||||
Authentik({
|
Authentik({
|
||||||
clientId: env.AUTHENTIK_CLIENT_ID,
|
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||||
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
|
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
|
||||||
issuer: env.AUTHENTIK_ISSUER
|
issuer: env.AUTHENTIK_ISSUER
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
Credentials({
|
Credentials({
|
||||||
id: 'credentials',
|
id: 'credentials',
|
||||||
name: 'credentials',
|
name: 'credentials',
|
||||||
credentials: {
|
credentials: {
|
||||||
username: { label: 'Username', type: 'text' },
|
username: { label: 'Username', type: 'text' },
|
||||||
password: { label: 'Password', type: 'password' }
|
password: { label: 'Password', type: 'password' }
|
||||||
},
|
},
|
||||||
async authorize(credentials) {
|
async authorize(credentials) {
|
||||||
if (!credentials?.username || !credentials?.password) {
|
if (!credentials?.username || !credentials?.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.username, credentials.username as string)
|
where: eq(users.username, credentials.username as string)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user || !user.password) {
|
if (!user || !user.password) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidPassword = await bcrypt.compare(
|
const isValidPassword = await bcrypt.compare(credentials.password as string, user.password);
|
||||||
credentials.password as string,
|
|
||||||
user.password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email || undefined,
|
email: user.email || undefined,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
image: user.image
|
image: user.image
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
pages: {
|
pages: {
|
||||||
signIn: '/signin'
|
signIn: '/signin'
|
||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
async signIn({ user }) {
|
async signIn({ user }) {
|
||||||
if (user?.id) {
|
if (user?.id) {
|
||||||
await db.update(users)
|
await db.update(users).set({ lastLogin: new Date() }).where(eq(users.id, user.id));
|
||||||
.set({ lastLogin: new Date() })
|
}
|
||||||
.where(eq(users.id, user.id));
|
return true;
|
||||||
}
|
},
|
||||||
return true;
|
async jwt({ token, user }) {
|
||||||
},
|
if (user) {
|
||||||
async jwt({ token, user }) {
|
token.id = user.id;
|
||||||
if (user) {
|
}
|
||||||
token.id = user.id;
|
return token;
|
||||||
}
|
},
|
||||||
return token;
|
async session({ session, token }) {
|
||||||
},
|
if (token && session.user) {
|
||||||
async session({ session, token }) {
|
session.user.id = token.id as string;
|
||||||
if (token && session.user) {
|
}
|
||||||
session.user.id = token.id as string;
|
return session;
|
||||||
}
|
}
|
||||||
return session;
|
},
|
||||||
}
|
secret: env.AUTH_SECRET,
|
||||||
},
|
trustHost: env.AUTH_TRUST_HOST === 'true'
|
||||||
secret: env.AUTH_SECRET,
|
|
||||||
trustHost: env.AUTH_TRUST_HOST === 'true'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);
|
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);
|
||||||
|
|||||||
@@ -1,158 +1,161 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
|
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
|
||||||
import { getLocalWishlists, forgetLocalWishlist, toggleLocalFavorite, type LocalWishlist } from '$lib/utils/localWishlists';
|
import {
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
getLocalWishlists,
|
||||||
import { Star } from 'lucide-svelte';
|
forgetLocalWishlist,
|
||||||
import { onMount } from 'svelte';
|
toggleLocalFavorite,
|
||||||
|
type LocalWishlist
|
||||||
|
} from '$lib/utils/localWishlists';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
import { Star } from '@lucide/svelte';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
fallbackColor = null,
|
fallbackColor = null,
|
||||||
fallbackTheme = null
|
fallbackTheme = null
|
||||||
}: {
|
}: {
|
||||||
isAuthenticated?: boolean;
|
isAuthenticated?: boolean;
|
||||||
fallbackColor?: string | null;
|
fallbackColor?: string | null;
|
||||||
fallbackTheme?: string | null;
|
fallbackTheme?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
let localWishlists = $state<LocalWishlist[]>([]);
|
let localWishlists = $state<LocalWishlist[]>([]);
|
||||||
let enrichedWishlists = $state<any[]>([]);
|
let enrichedWishlists = $state<Array<Record<string, unknown>>>([]);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
localWishlists = getLocalWishlists();
|
localWishlists = getLocalWishlists();
|
||||||
|
|
||||||
const promises = localWishlists.map(async (local) => {
|
const promises = localWishlists.map(async (local) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
|
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
isFavorite: local.isFavorite || false
|
isFavorite: local.isFavorite || false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch wishlist data:', error);
|
console.error('Failed to fetch wishlist data:', error);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: local.ownerToken,
|
id: local.ownerToken,
|
||||||
title: local.title,
|
title: local.title,
|
||||||
ownerToken: local.ownerToken,
|
ownerToken: local.ownerToken,
|
||||||
publicToken: local.publicToken,
|
publicToken: local.publicToken,
|
||||||
createdAt: local.createdAt,
|
createdAt: local.createdAt,
|
||||||
isFavorite: local.isFavorite || false,
|
isFavorite: local.isFavorite || false,
|
||||||
items: [],
|
items: [],
|
||||||
theme: null,
|
theme: null,
|
||||||
color: null
|
color: null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
enrichedWishlists = await Promise.all(promises);
|
enrichedWishlists = await Promise.all(promises);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshEnrichedWishlists() {
|
async function refreshEnrichedWishlists() {
|
||||||
const promises = localWishlists.map(async (local) => {
|
const promises = localWishlists.map(async (local) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
|
const response = await fetch(`/api/wishlist/${local.ownerToken}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
isFavorite: local.isFavorite || false
|
isFavorite: local.isFavorite || false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch wishlist data:', error);
|
console.error('Failed to fetch wishlist data:', error);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
id: local.ownerToken,
|
id: local.ownerToken,
|
||||||
title: local.title,
|
title: local.title,
|
||||||
ownerToken: local.ownerToken,
|
ownerToken: local.ownerToken,
|
||||||
publicToken: local.publicToken,
|
publicToken: local.publicToken,
|
||||||
createdAt: local.createdAt,
|
createdAt: local.createdAt,
|
||||||
isFavorite: local.isFavorite || false,
|
isFavorite: local.isFavorite || false,
|
||||||
items: [],
|
items: [],
|
||||||
theme: null,
|
theme: null,
|
||||||
color: null
|
color: null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
enrichedWishlists = await Promise.all(promises);
|
enrichedWishlists = await Promise.all(promises);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleForget(ownerToken: string) {
|
async function handleForget(ownerToken: string) {
|
||||||
forgetLocalWishlist(ownerToken);
|
forgetLocalWishlist(ownerToken);
|
||||||
localWishlists = getLocalWishlists();
|
localWishlists = getLocalWishlists();
|
||||||
await refreshEnrichedWishlists();
|
await refreshEnrichedWishlists();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleToggleFavorite(ownerToken: string) {
|
async function handleToggleFavorite(ownerToken: string) {
|
||||||
toggleLocalFavorite(ownerToken);
|
toggleLocalFavorite(ownerToken);
|
||||||
localWishlists = getLocalWishlists();
|
localWishlists = getLocalWishlists();
|
||||||
await refreshEnrichedWishlists();
|
await refreshEnrichedWishlists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use enriched wishlists which have full data including theme and color
|
// Use enriched wishlists which have full data including theme and color
|
||||||
const transformedWishlists = $derived(() => enrichedWishlists);
|
const transformedWishlists = $derived(() => enrichedWishlists);
|
||||||
|
|
||||||
// Description depends on authentication status
|
// Description depends on authentication status
|
||||||
const sectionDescription = $derived(() => {
|
const sectionDescription = $derived(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return t.dashboard.localWishlistsAuthDescription || "Wishlists stored in your browser that haven't been claimed yet.";
|
return (
|
||||||
}
|
t.dashboard.localWishlistsAuthDescription ||
|
||||||
return t.dashboard.localWishlistsDescription || "Wishlists stored in your browser. Sign in to save them permanently.";
|
"Wishlists stored in your browser that haven't been claimed yet."
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
t.dashboard.localWishlistsDescription ||
|
||||||
|
'Wishlists stored in your browser. Sign in to save them permanently.'
|
||||||
|
);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WishlistSection
|
<WishlistSection
|
||||||
title={t.dashboard.localWishlists || "Local Wishlists"}
|
title={t.dashboard.localWishlists || 'Local Wishlists'}
|
||||||
description={sectionDescription()}
|
description={sectionDescription()}
|
||||||
items={transformedWishlists()}
|
items={transformedWishlists()}
|
||||||
emptyMessage={t.dashboard.emptyLocalWishlists || "No local wishlists yet"}
|
emptyMessage={t.dashboard.emptyLocalWishlists || 'No local wishlists yet'}
|
||||||
emptyActionLabel={t.dashboard.createLocalWishlist || "Create local wishlist"}
|
emptyActionLabel={t.dashboard.createLocalWishlist || 'Create local wishlist'}
|
||||||
emptyActionHref="/"
|
emptyActionHref="/"
|
||||||
showCreateButton={true}
|
showCreateButton={true}
|
||||||
fallbackColor={fallbackColor}
|
{fallbackColor}
|
||||||
fallbackTheme={fallbackTheme}
|
{fallbackTheme}
|
||||||
>
|
>
|
||||||
{#snippet actions(wishlist, unlocked)}
|
{#snippet actions(wishlist: Record<string, unknown>, unlocked: boolean)}
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button size="sm" variant="outline" onclick={() => handleToggleFavorite(wishlist.ownerToken as string)}>
|
||||||
size="sm"
|
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
|
||||||
variant="outline"
|
</Button>
|
||||||
onclick={() => handleToggleFavorite(wishlist.ownerToken)}
|
<Button
|
||||||
>
|
size="sm"
|
||||||
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken as string}/edit`)}
|
||||||
</Button>
|
>
|
||||||
<Button
|
{t.dashboard.manage}
|
||||||
size="sm"
|
</Button>
|
||||||
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
<Button
|
||||||
>
|
size="sm"
|
||||||
{t.dashboard.manage}
|
variant="outline"
|
||||||
</Button>
|
onclick={() => {
|
||||||
<Button
|
navigator.clipboard.writeText(
|
||||||
size="sm"
|
`${window.location.origin}/wishlist/${wishlist.publicToken as string}`
|
||||||
variant="outline"
|
);
|
||||||
onclick={() => {
|
}}
|
||||||
navigator.clipboard.writeText(
|
>
|
||||||
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
{t.dashboard.copyLink}
|
||||||
);
|
</Button>
|
||||||
}}
|
{#if unlocked}
|
||||||
>
|
<Button size="sm" variant="destructive" onclick={() => handleForget(wishlist.ownerToken as string)}>
|
||||||
{t.dashboard.copyLink}
|
{t.dashboard.forget || 'Forget'}
|
||||||
</Button>
|
</Button>
|
||||||
{#if unlocked}
|
{/if}
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
{/snippet}
|
||||||
variant="destructive"
|
|
||||||
onclick={() => handleForget(wishlist.ownerToken)}
|
|
||||||
>
|
|
||||||
{t.dashboard.forget || "Forget"}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/snippet}
|
|
||||||
</WishlistSection>
|
</WishlistSection>
|
||||||
|
|||||||
@@ -1,55 +1,60 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import {
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
Card,
|
||||||
import type { Snippet } from 'svelte';
|
CardContent,
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
CardDescription,
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/components/ui/card';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
itemCount,
|
itemCount,
|
||||||
color = null,
|
color = null,
|
||||||
theme = null,
|
theme = null,
|
||||||
fallbackColor = null,
|
fallbackColor = null,
|
||||||
fallbackTheme = null,
|
fallbackTheme = null,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
fallbackColor?: string | null;
|
fallbackColor?: string | null;
|
||||||
fallbackTheme?: string | null;
|
fallbackTheme?: string | null;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const finalColor = $derived(color || fallbackColor);
|
const finalColor = $derived(color || fallbackColor);
|
||||||
const finalTheme = $derived(theme || fallbackTheme);
|
const finalTheme = $derived(theme || fallbackTheme);
|
||||||
const cardStyle = $derived(getCardStyle(color, fallbackColor));
|
const cardStyle = $derived(getCardStyle(color, fallbackColor));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
|
<Card style={cardStyle} class="h-full flex flex-col relative overflow-hidden">
|
||||||
<ThemeCard themeName={finalTheme} color={finalColor} />
|
<ThemeCard themeName={finalTheme} color={finalColor} />
|
||||||
<CardHeader class="flex-shrink-0 relative z-10">
|
<CardHeader class="flex-shrink-0 relative z-10">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2">
|
||||||
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
|
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
|
||||||
<span class="truncate">{title}</span>
|
<span class="truncate">{title}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<span class="text-sm text-muted-foreground flex-shrink-0">
|
<span class="text-sm text-muted-foreground flex-shrink-0">
|
||||||
{itemCount} item{itemCount === 1 ? '' : 's'}
|
{itemCount} item{itemCount === 1 ? '' : 's'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if description}
|
{#if description}
|
||||||
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
|
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-2 flex-1 flex flex-col justify-end relative z-10">
|
<CardContent class="space-y-2 flex-1 flex flex-col justify-end relative z-10">
|
||||||
{#if children}
|
{#if children}
|
||||||
<div>
|
<div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,97 +1,102 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import {
|
||||||
import { Button } from '$lib/components/ui/button';
|
Card,
|
||||||
import EmptyState from '$lib/components/layout/EmptyState.svelte';
|
CardContent,
|
||||||
import type { Snippet } from 'svelte';
|
CardDescription,
|
||||||
import { flip } from 'svelte/animate';
|
CardHeader,
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
CardTitle
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
} from '$lib/components/ui/card';
|
||||||
|
import EmptyState from '$lib/components/layout/EmptyState.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
items,
|
items,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
emptyActionLabel,
|
emptyActionLabel,
|
||||||
emptyActionHref,
|
emptyActionHref,
|
||||||
fallbackColor = null,
|
fallbackColor = null,
|
||||||
fallbackTheme = null,
|
fallbackTheme = null,
|
||||||
headerAction,
|
headerAction,
|
||||||
searchBar,
|
searchBar,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
items: any[];
|
items: Array<Record<string, unknown>>;
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
emptyActionLabel?: string;
|
emptyActionLabel?: string;
|
||||||
emptyActionHref?: string;
|
emptyActionHref?: string;
|
||||||
fallbackColor?: string | null;
|
fallbackColor?: string | null;
|
||||||
fallbackTheme?: string | null;
|
fallbackTheme?: string | null;
|
||||||
headerAction?: Snippet;
|
headerAction?: Snippet;
|
||||||
searchBar?: Snippet;
|
searchBar?: Snippet;
|
||||||
children: Snippet<[any]>;
|
children: Snippet<[Record<string, unknown>]>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const cardStyle = $derived(getCardStyle(fallbackColor, null));
|
const cardStyle = $derived(getCardStyle(fallbackColor, null));
|
||||||
|
|
||||||
let scrollContainer: HTMLElement | null = null;
|
let scrollContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
function handleWheel(event: WheelEvent) {
|
function handleWheel(event: WheelEvent) {
|
||||||
if (!scrollContainer) return;
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
// Check if we have horizontal overflow
|
// Check if we have horizontal overflow
|
||||||
const hasHorizontalScroll = scrollContainer.scrollWidth > scrollContainer.clientWidth;
|
const hasHorizontalScroll = scrollContainer.scrollWidth > scrollContainer.clientWidth;
|
||||||
|
|
||||||
if (hasHorizontalScroll && event.deltaY !== 0) {
|
if (hasHorizontalScroll && event.deltaY !== 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
scrollContainer.scrollLeft += event.deltaY;
|
scrollContainer.scrollLeft += event.deltaY;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
<Card style={cardStyle} class="relative overflow-hidden">
|
||||||
<ThemeCard themeName={fallbackTheme} color={fallbackColor} showPattern={false} />
|
<ThemeCard themeName={fallbackTheme} color={fallbackColor} showPattern={false} />
|
||||||
<CardHeader class="relative z-10">
|
<CardHeader class="relative z-10">
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<CardTitle>{title}</CardTitle>
|
<CardTitle>{title}</CardTitle>
|
||||||
<CardDescription>{description}</CardDescription>
|
<CardDescription>{description}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{#if headerAction}
|
{#if headerAction}
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
{@render headerAction()}
|
{@render headerAction()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if searchBar}
|
{#if searchBar}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{@render searchBar()}
|
{@render searchBar()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="relative z-10">
|
<CardContent class="relative z-10">
|
||||||
{#if items && items.length > 0}
|
{#if items && items.length > 0}
|
||||||
<div
|
<div
|
||||||
bind:this={scrollContainer}
|
bind:this={scrollContainer}
|
||||||
onwheel={handleWheel}
|
onwheel={handleWheel}
|
||||||
class="flex overflow-x-auto gap-4 pb-4 -mx-6 px-6"
|
class="flex overflow-x-auto gap-4 pb-4 -mx-6 px-6"
|
||||||
>
|
>
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<div class="flex-shrink-0 w-80" animate:flip={{ duration: 300 }}>
|
<div class="flex-shrink-0 w-80" animate:flip={{ duration: 300 }}>
|
||||||
{@render children(item)}
|
{@render children(item)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
message={emptyMessage}
|
message={emptyMessage}
|
||||||
description={emptyDescription}
|
description={emptyDescription}
|
||||||
actionLabel={emptyActionLabel}
|
actionLabel={emptyActionLabel}
|
||||||
actionHref={emptyActionHref}
|
actionHref={emptyActionHref}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,166 +1,178 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import WishlistGrid from '$lib/components/dashboard/WishlistGrid.svelte';
|
import WishlistGrid from '$lib/components/dashboard/WishlistGrid.svelte';
|
||||||
import WishlistCard from '$lib/components/dashboard/WishlistCard.svelte';
|
import WishlistCard from '$lib/components/dashboard/WishlistCard.svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import { Star } from 'lucide-svelte';
|
import SearchBar from '$lib/components/ui/SearchBar.svelte';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
|
||||||
import SearchBar from '$lib/components/ui/SearchBar.svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
type WishlistItem = any; // You can make this more specific based on your types
|
interface WishlistItem {
|
||||||
|
id?: string;
|
||||||
|
wishlist?: Record<string, unknown>;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
endDate?: string | Date;
|
||||||
|
createdAt?: string | Date;
|
||||||
|
items?: Array<{ title: string }>;
|
||||||
|
user?: { name?: string; username?: string };
|
||||||
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
items,
|
items,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
emptyActionLabel,
|
emptyActionLabel,
|
||||||
emptyActionHref,
|
emptyActionHref,
|
||||||
showCreateButton = false,
|
showCreateButton = false,
|
||||||
hideIfEmpty = false,
|
hideIfEmpty = false,
|
||||||
fallbackColor = null,
|
fallbackColor = null,
|
||||||
fallbackTheme = null,
|
fallbackTheme = null,
|
||||||
actions
|
actions
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
items: WishlistItem[];
|
items: WishlistItem[];
|
||||||
emptyMessage: string;
|
emptyMessage: string;
|
||||||
emptyDescription?: string;
|
emptyDescription?: string;
|
||||||
emptyActionLabel?: string;
|
emptyActionLabel?: string;
|
||||||
emptyActionHref?: string;
|
emptyActionHref?: string;
|
||||||
showCreateButton?: boolean;
|
showCreateButton?: boolean;
|
||||||
hideIfEmpty?: boolean;
|
hideIfEmpty?: boolean;
|
||||||
fallbackColor?: string | null;
|
fallbackColor?: string | null;
|
||||||
fallbackTheme?: string | null;
|
fallbackTheme?: string | null;
|
||||||
actions: Snippet<[WishlistItem, boolean]>; // item, unlocked
|
actions: Snippet<[WishlistItem, boolean]>; // item, unlocked
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
let unlocked = $state(false);
|
let unlocked = $state(false);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
|
||||||
// Filter items based on search query
|
// Filter items based on search query
|
||||||
const filteredItems = $derived(() => {
|
const filteredItems = $derived(() => {
|
||||||
if (!searchQuery.trim()) return items;
|
if (!searchQuery.trim()) return items;
|
||||||
|
|
||||||
return items.filter(item => {
|
return items.filter((item) => {
|
||||||
const title = item.title || item.wishlist?.title || '';
|
const title = item.title || item.wishlist?.title || '';
|
||||||
const description = item.description || item.wishlist?.description || '';
|
const description = item.description || item.wishlist?.description || '';
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
|
|
||||||
return title.toLowerCase().includes(query) || description.toLowerCase().includes(query);
|
return title.toLowerCase().includes(query) || description.toLowerCase().includes(query);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort items by favorite, end date, then created date
|
// Sort items by favorite, end date, then created date
|
||||||
const sortedItems = $derived(() => {
|
const sortedItems = $derived(() => {
|
||||||
return [...filteredItems()].sort((a, b) => {
|
return [...filteredItems()].sort((a, b) => {
|
||||||
// Handle both direct wishlists and saved wishlists
|
// Handle both direct wishlists and saved wishlists
|
||||||
const aItem = a.wishlist || a;
|
const aItem = a.wishlist || a;
|
||||||
const bItem = b.wishlist || b;
|
const bItem = b.wishlist || b;
|
||||||
|
|
||||||
// Sort by favorite first
|
// Sort by favorite first
|
||||||
if (a.isFavorite && !b.isFavorite) return -1;
|
if (a.isFavorite && !b.isFavorite) return -1;
|
||||||
if (!a.isFavorite && b.isFavorite) return 1;
|
if (!a.isFavorite && b.isFavorite) return 1;
|
||||||
|
|
||||||
// Then by end date
|
// Then by end date
|
||||||
const aHasEndDate = !!aItem.endDate;
|
const aHasEndDate = !!aItem.endDate;
|
||||||
const bHasEndDate = !!bItem.endDate;
|
const bHasEndDate = !!bItem.endDate;
|
||||||
|
|
||||||
if (aHasEndDate && !bHasEndDate) return -1;
|
if (aHasEndDate && !bHasEndDate) return -1;
|
||||||
if (!aHasEndDate && bHasEndDate) return 1;
|
if (!aHasEndDate && bHasEndDate) return 1;
|
||||||
|
|
||||||
if (aHasEndDate && bHasEndDate) {
|
if (aHasEndDate && bHasEndDate) {
|
||||||
return new Date(aItem.endDate!).getTime() - new Date(bItem.endDate!).getTime();
|
return new Date(aItem.endDate!).getTime() - new Date(bItem.endDate!).getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finally by created date (most recent first)
|
// Finally by created date (most recent first)
|
||||||
const aCreatedAt = a.createdAt || aItem.createdAt;
|
const aCreatedAt = a.createdAt || aItem.createdAt;
|
||||||
const bCreatedAt = b.createdAt || bItem.createdAt;
|
const bCreatedAt = b.createdAt || bItem.createdAt;
|
||||||
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime();
|
return new Date(bCreatedAt).getTime() - new Date(aCreatedAt).getTime();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatEndDate(date: Date | string | null): string | null {
|
function formatEndDate(date: Date | string | null): string | null {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
const d = new Date(date);
|
const d = new Date(date);
|
||||||
return d.toLocaleDateString(languageStore.t.date.format.short, { year: 'numeric', month: 'short', day: 'numeric' });
|
return d.toLocaleDateString(languageStore.t.date.format.short, {
|
||||||
}
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWishlistDescription(item: any): string | null {
|
function getWishlistDescription(item: WishlistItem): string | null {
|
||||||
const wishlist = item.wishlist || item;
|
const wishlist = item.wishlist || item;
|
||||||
if (!wishlist) return null;
|
if (!wishlist) return null;
|
||||||
|
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
const topItems = wishlist.items?.slice(0, 3).map((i: any) => i.title) || [];
|
const topItems = wishlist.items?.slice(0, 3).map((i: { title: string }) => i.title) || [];
|
||||||
if (topItems.length > 0) {
|
if (topItems.length > 0) {
|
||||||
lines.push(topItems.join(', '));
|
lines.push(topItems.join(', '));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wishlist.user?.name || wishlist.user?.username) {
|
if (wishlist.user?.name || wishlist.user?.username) {
|
||||||
const ownerName = wishlist.user.name || wishlist.user.username;
|
const ownerName = wishlist.user.name || wishlist.user.username;
|
||||||
lines.push(`${t.dashboard.by} ${ownerName}`);
|
lines.push(`${t.dashboard.by} ${ownerName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wishlist.endDate) {
|
if (wishlist.endDate) {
|
||||||
lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`);
|
lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines.length > 0 ? lines.join('\n') : null;
|
return lines.length > 0 ? lines.join('\n') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide entire section if hideIfEmpty is true and there are no items
|
// Hide entire section if hideIfEmpty is true and there are no items
|
||||||
const shouldShow = $derived(() => {
|
const shouldShow = $derived(() => {
|
||||||
return !hideIfEmpty || items.length > 0;
|
return !hideIfEmpty || items.length > 0;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if shouldShow()}
|
{#if shouldShow()}
|
||||||
<WishlistGrid
|
<WishlistGrid
|
||||||
{title}
|
{title}
|
||||||
{description}
|
{description}
|
||||||
items={sortedItems() || []}
|
items={sortedItems() || []}
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
{emptyDescription}
|
{emptyDescription}
|
||||||
{emptyActionLabel}
|
{emptyActionLabel}
|
||||||
{emptyActionHref}
|
{emptyActionHref}
|
||||||
{fallbackColor}
|
{fallbackColor}
|
||||||
{fallbackTheme}
|
{fallbackTheme}
|
||||||
>
|
>
|
||||||
{#snippet headerAction()}
|
{#snippet headerAction()}
|
||||||
<div class="flex flex-col sm:flex-row gap-2">
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
{#if showCreateButton}
|
{#if showCreateButton}
|
||||||
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
|
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<UnlockButton bind:unlocked />
|
<UnlockButton bind:unlocked />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet searchBar()}
|
{#snippet searchBar()}
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<SearchBar bind:value={searchQuery} />
|
<SearchBar bind:value={searchQuery} />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet children(item)}
|
{#snippet children(item)}
|
||||||
{@const wishlist = item.wishlist || item}
|
{@const wishlist = item.wishlist || item}
|
||||||
<WishlistCard
|
<WishlistCard
|
||||||
title={wishlist.title}
|
title={wishlist.title}
|
||||||
description={getWishlistDescription(item)}
|
description={getWishlistDescription(item)}
|
||||||
itemCount={wishlist.items?.length || 0}
|
itemCount={wishlist.items?.length || 0}
|
||||||
color={wishlist.color}
|
color={wishlist.color}
|
||||||
theme={wishlist.theme}
|
theme={wishlist.theme}
|
||||||
fallbackColor={fallbackColor}
|
{fallbackColor}
|
||||||
fallbackTheme={fallbackTheme}
|
{fallbackTheme}
|
||||||
>
|
>
|
||||||
{@render actions(item, unlocked)}
|
{@render actions(item, unlocked)}
|
||||||
</WishlistCard>
|
</WishlistCard>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</WishlistGrid>
|
</WishlistGrid>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,92 +1,95 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
|
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
|
||||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||||
import { signOut } from '@auth/sveltekit/client';
|
import { signOut } from '@auth/sveltekit/client';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
dashboardTheme = 'none',
|
dashboardTheme = 'none',
|
||||||
dashboardColor = null,
|
dashboardColor = null,
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
onThemeUpdate,
|
onThemeUpdate,
|
||||||
onColorUpdate
|
onColorUpdate
|
||||||
}: {
|
}: {
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
dashboardTheme?: string;
|
dashboardTheme?: string;
|
||||||
dashboardColor?: string | null;
|
dashboardColor?: string | null;
|
||||||
isAuthenticated?: boolean;
|
isAuthenticated?: boolean;
|
||||||
onThemeUpdate?: (theme: string | null) => void;
|
onThemeUpdate?: (theme: string | null) => void;
|
||||||
onColorUpdate?: (color: string | null) => void;
|
onColorUpdate?: (color: string | null) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
async function handleThemeChange(theme: string) {
|
async function handleThemeChange(theme: string) {
|
||||||
if (onThemeUpdate) {
|
if (onThemeUpdate) {
|
||||||
onThemeUpdate(theme);
|
onThemeUpdate(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('theme', theme);
|
formData.append('theme', theme);
|
||||||
|
|
||||||
await fetch('?/updateDashboardTheme', {
|
await fetch('?/updateDashboardTheme', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let localColor = $state(dashboardColor);
|
let localColor = $derived(dashboardColor);
|
||||||
|
|
||||||
$effect(() => {
|
async function handleColorChange() {
|
||||||
localColor = dashboardColor;
|
if (onColorUpdate) {
|
||||||
});
|
onColorUpdate(localColor);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleColorChange() {
|
if (isAuthenticated) {
|
||||||
if (onColorUpdate) {
|
const formData = new FormData();
|
||||||
onColorUpdate(localColor);
|
if (localColor) {
|
||||||
}
|
formData.append('color', localColor);
|
||||||
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
await fetch('?/updateDashboardColor', {
|
||||||
const formData = new FormData();
|
method: 'POST',
|
||||||
if (localColor) {
|
body: formData
|
||||||
formData.append('color', localColor);
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
await fetch('?/updateDashboardColor', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
|
<h1 class="text-3xl font-bold">{t.nav.dashboard}</h1>
|
||||||
{#if isAuthenticated}
|
{#if isAuthenticated}
|
||||||
<p class="text-muted-foreground truncate">{t.dashboard.welcomeBack}, {userName || userEmail}</p>
|
<p class="text-muted-foreground truncate">
|
||||||
{:else}
|
{t.dashboard.welcomeBack}, {userName || userEmail}
|
||||||
<p class="text-muted-foreground">{t.dashboard.anonymousDashboard || "Your local wishlists"}</p>
|
</p>
|
||||||
{/if}
|
{:else}
|
||||||
</div>
|
<p class="text-muted-foreground">
|
||||||
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
{t.dashboard.anonymousDashboard || 'Your local wishlists'}
|
||||||
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
|
</p>
|
||||||
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
|
{/if}
|
||||||
<LanguageToggle color={localColor} />
|
</div>
|
||||||
<ThemeToggle />
|
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||||
{#if isAuthenticated}
|
<ColorPicker bind:color={localColor} onchange={handleColorChange} size="sm" />
|
||||||
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>{t.auth.signOut}</Button>
|
<ThemePicker value={dashboardTheme} onValueChange={handleThemeChange} color={localColor} />
|
||||||
{:else}
|
<LanguageToggle color={localColor} />
|
||||||
<Button variant="outline" onclick={() => (window.location.href = '/signin')}>{t.auth.signIn}</Button>
|
<ThemeToggle />
|
||||||
{/if}
|
{#if isAuthenticated}
|
||||||
</div>
|
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}
|
||||||
|
>{t.auth.signOut}</Button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Button variant="outline" onclick={() => (window.location.href = '/signin')}
|
||||||
|
>{t.auth.signIn}</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,45 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
message,
|
message,
|
||||||
description,
|
description,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
actionHref,
|
actionHref,
|
||||||
onclick,
|
onclick,
|
||||||
children
|
children
|
||||||
}: {
|
}: {
|
||||||
message: string;
|
message: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
actionLabel?: string;
|
actionLabel?: string;
|
||||||
actionHref?: string;
|
actionHref?: string;
|
||||||
onclick?: () => void;
|
onclick?: () => void;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center py-8 text-muted-foreground">
|
<div class="text-center py-8 text-muted-foreground">
|
||||||
<p class="text-base">{message}</p>
|
<p class="text-base">{message}</p>
|
||||||
{#if description}
|
{#if description}
|
||||||
<p class="text-sm mt-2">{description}</p>
|
<p class="text-sm mt-2">{description}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if children}
|
{#if children}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
{:else if actionLabel}
|
{:else if actionLabel}
|
||||||
<Button
|
<Button
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (onclick) {
|
if (onclick) {
|
||||||
onclick();
|
onclick();
|
||||||
} else if (actionHref) {
|
} else if (actionHref) {
|
||||||
window.location.href = actionHref;
|
window.location.href = actionHref;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,44 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
import { LayoutDashboard } from 'lucide-svelte';
|
import { LayoutDashboard } from '@lucide/svelte';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
isAuthenticated = false,
|
isAuthenticated = false,
|
||||||
showDashboardLink = false,
|
color = null
|
||||||
color = null
|
}: {
|
||||||
}: {
|
isAuthenticated?: boolean;
|
||||||
isAuthenticated?: boolean;
|
color?: string | null;
|
||||||
showDashboardLink?: boolean;
|
} = $props();
|
||||||
color?: string | null;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full">
|
<nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full">
|
||||||
{#if isAuthenticated}
|
{#if isAuthenticated}
|
||||||
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/dashboard')} class="px-2 sm:px-3">
|
<Button
|
||||||
<LayoutDashboard class="w-4 h-4" />
|
variant="outline"
|
||||||
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
|
size="sm"
|
||||||
</Button>
|
onclick={() => (window.location.href = '/dashboard')}
|
||||||
{:else}
|
class="px-2 sm:px-3"
|
||||||
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')} class="px-2 sm:px-3">
|
>
|
||||||
{t.auth.signIn}
|
<LayoutDashboard class="w-4 h-4" />
|
||||||
</Button>
|
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
|
||||||
{/if}
|
</Button>
|
||||||
<div class="ml-auto flex items-center gap-1 sm:gap-2">
|
{:else}
|
||||||
<LanguageToggle {color} />
|
<Button
|
||||||
<ThemeToggle size="sm" {color} />
|
variant="outline"
|
||||||
</div>
|
size="sm"
|
||||||
|
onclick={() => (window.location.href = '/signin')}
|
||||||
|
class="px-2 sm:px-3"
|
||||||
|
>
|
||||||
|
{t.auth.signIn}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<div class="ml-auto flex items-center gap-1 sm:gap-2">
|
||||||
|
<LanguageToggle {color} />
|
||||||
|
<ThemeToggle size="sm" {color} />
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
|
import ThemeBackground from '$lib/components/themes/ThemeBackground.svelte';
|
||||||
import { hexToRgba } from '$lib/utils/colors';
|
import { hexToRgba } from '$lib/utils/colors';
|
||||||
import { themeStore } from '$lib/stores/theme.svelte';
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
children,
|
children,
|
||||||
maxWidth = '6xl',
|
maxWidth = '6xl',
|
||||||
theme = null,
|
theme = null,
|
||||||
themeColor = null
|
themeColor = null
|
||||||
}: {
|
}: {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
themeColor?: string | null;
|
themeColor?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const backgroundStyle = $derived.by(() => {
|
const backgroundStyle = $derived.by(() => {
|
||||||
if (!themeColor) return '';
|
if (!themeColor) return '';
|
||||||
|
|
||||||
const isDark = themeStore.getResolvedTheme() === 'dark';
|
const isDark = themeStore.getResolvedTheme() === 'dark';
|
||||||
const tintedColor = hexToRgba(themeColor, 0.15);
|
const tintedColor = hexToRgba(themeColor, 0.15);
|
||||||
|
|
||||||
return isDark
|
return isDark
|
||||||
? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;`
|
? `background: linear-gradient(${tintedColor}, ${tintedColor}), #000000;`
|
||||||
: `background-color: ${tintedColor};`;
|
: `background-color: ${tintedColor};`;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden" style={backgroundStyle}>
|
<div class="min-h-screen p-4 md:p-8 relative overflow-hidden" style={backgroundStyle}>
|
||||||
<ThemeBackground themeName={theme} color={themeColor} />
|
<ThemeBackground themeName={theme} color={themeColor} />
|
||||||
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
|
<div class="max-w-{maxWidth} mx-auto space-y-6 relative z-10">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import TopPattern from './svgs/TopPattern.svelte';
|
import TopPattern from './svgs/TopPattern.svelte';
|
||||||
import BottomPattern from './svgs/BottomPattern.svelte';
|
import BottomPattern from './svgs/BottomPattern.svelte';
|
||||||
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
|
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||||
import { themeStore } from '$lib/stores/theme.svelte';
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
themeName,
|
themeName,
|
||||||
showTop = true,
|
showTop = true,
|
||||||
showBottom = true,
|
showBottom = true
|
||||||
color
|
}: {
|
||||||
}: {
|
themeName?: string | null;
|
||||||
themeName?: string | null;
|
showTop?: boolean;
|
||||||
showTop?: boolean;
|
showBottom?: boolean;
|
||||||
showBottom?: boolean;
|
} = $props();
|
||||||
color?: string;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const theme = $derived(getTheme(themeName));
|
const theme = $derived(getTheme(themeName));
|
||||||
const patternColor = $derived.by(() => {
|
const patternColor = $derived.by(() => {
|
||||||
const isDark = themeStore.getResolvedTheme() === 'dark';
|
const isDark = themeStore.getResolvedTheme() === 'dark';
|
||||||
return isDark ? '#FFFFFF' : '#000000';
|
return isDark ? '#FFFFFF' : '#000000';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if theme.pattern !== 'none'}
|
{#if theme.pattern !== 'none'}
|
||||||
{#if showTop}
|
{#if showTop}
|
||||||
<TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
<TopPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showBottom}
|
{#if showBottom}
|
||||||
<BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
<BottomPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import CardPattern from './svgs/CardPattern.svelte';
|
import CardPattern from './svgs/CardPattern.svelte';
|
||||||
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
|
import { getTheme, PATTERN_OPACITY } from '$lib/utils/themes';
|
||||||
import { themeStore } from '$lib/stores/theme.svelte';
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
themeName,
|
themeName,
|
||||||
color,
|
showPattern = true
|
||||||
showPattern = true
|
}: {
|
||||||
}: {
|
themeName?: string | null;
|
||||||
themeName?: string | null;
|
showPattern?: boolean;
|
||||||
color?: string | null;
|
} = $props();
|
||||||
showPattern?: boolean;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const theme = $derived(getTheme(themeName));
|
const theme = $derived(getTheme(themeName));
|
||||||
const patternColor = $derived.by(() => {
|
const patternColor = $derived.by(() => {
|
||||||
const isDark = themeStore.getResolvedTheme() === 'dark';
|
const isDark = themeStore.getResolvedTheme() === 'dark';
|
||||||
return isDark ? '#FFFFFF' : '#000000';
|
return isDark ? '#FFFFFF' : '#000000';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if showPattern && theme.pattern !== 'none'}
|
{#if showPattern && theme.pattern !== 'none'}
|
||||||
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
<CardPattern pattern={theme.pattern} color={patternColor} opacity={PATTERN_OPACITY} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { asset } from '$app/paths';
|
import { asset } from '$app/paths';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
pattern = 'none',
|
pattern = 'none',
|
||||||
color = '#000000',
|
color = '#000000',
|
||||||
opacity = 0.1
|
opacity = 0.1
|
||||||
}: {
|
}: {
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`));
|
const patternPath = $derived(asset(`/themes/${pattern}/bgbottom.svg`));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if pattern !== 'none'}
|
{#if pattern !== 'none'}
|
||||||
<div
|
<div
|
||||||
class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0"
|
class="fixed bottom-0 left-0 right-0 pointer-events-none overflow-hidden z-0"
|
||||||
style="
|
style="
|
||||||
mask-image: url({patternPath});
|
mask-image: url({patternPath});
|
||||||
mask-size: cover;
|
mask-size: cover;
|
||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
@@ -26,5 +26,5 @@
|
|||||||
opacity: {opacity};
|
opacity: {opacity};
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { asset } from '$app/paths';
|
import { asset } from '$app/paths';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
pattern = 'none',
|
pattern = 'none',
|
||||||
color = '#000000',
|
color = '#000000',
|
||||||
opacity = 0.1
|
opacity = 0.1
|
||||||
}: {
|
}: {
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const patternPath = $derived(asset(`/themes/${pattern}/item.svg`));
|
const patternPath = $derived(asset(`/themes/${pattern}/item.svg`));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if pattern !== 'none'}
|
{#if pattern !== 'none'}
|
||||||
<div
|
<div
|
||||||
class="absolute bottom-0 top-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
|
class="absolute bottom-0 top-0 right-0 pointer-events-none overflow-hidden rounded-b-lg"
|
||||||
style="
|
style="
|
||||||
mask-image: url({patternPath});
|
mask-image: url({patternPath});
|
||||||
mask-size: cover;
|
mask-size: cover;
|
||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
@@ -26,5 +26,5 @@
|
|||||||
opacity: {opacity};
|
opacity: {opacity};
|
||||||
width: 100%;
|
width: 100%;
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { asset } from '$app/paths';
|
import { asset } from '$app/paths';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
pattern = 'none',
|
pattern = 'none',
|
||||||
color = '#000000',
|
color = '#000000',
|
||||||
opacity = 0.1
|
opacity = 0.1
|
||||||
}: {
|
}: {
|
||||||
pattern?: string;
|
pattern?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
opacity?: number;
|
opacity?: number;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`));
|
const patternPath = $derived(asset(`/themes/${pattern}/bgtop.svg`));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if pattern !== 'none'}
|
{#if pattern !== 'none'}
|
||||||
<div
|
<div
|
||||||
class="fixed top-0 right-0 left-0 pointer-events-none z-0"
|
class="fixed top-0 right-0 left-0 pointer-events-none z-0"
|
||||||
style="
|
style="
|
||||||
mask-image: url({patternPath});
|
mask-image: url({patternPath});
|
||||||
mask-size: cover;
|
mask-size: cover;
|
||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
@@ -26,5 +26,5 @@
|
|||||||
opacity: {opacity};
|
opacity: {opacity};
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,65 +1,62 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, Pencil } from 'lucide-svelte';
|
import { X, Pencil } from '@lucide/svelte';
|
||||||
import IconButton from './IconButton.svelte';
|
import IconButton from './IconButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
color = $bindable(null),
|
color = $bindable(null),
|
||||||
size = 'md',
|
size = 'md',
|
||||||
onchange
|
onchange
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
onchange?: () => void;
|
onchange?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'w-8 h-8',
|
sm: 'w-8 h-8',
|
||||||
md: 'w-10 h-10',
|
md: 'w-10 h-10',
|
||||||
lg: 'w-12 h-12'
|
lg: 'w-12 h-12'
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSizeClasses = {
|
const iconSizeClasses = {
|
||||||
sm: 'w-4 h-4',
|
sm: 'w-4 h-4',
|
||||||
md: 'w-4 h-4',
|
md: 'w-4 h-4',
|
||||||
lg: 'w-5 h-5'
|
lg: 'w-5 h-5'
|
||||||
};
|
};
|
||||||
|
|
||||||
const buttonSize = sizeClasses[size];
|
const buttonSize = sizeClasses[size];
|
||||||
const iconSize = iconSizeClasses[size];
|
const iconSize = iconSizeClasses[size];
|
||||||
|
|
||||||
function handleColorChange(e: Event) {
|
function handleColorChange(e: Event) {
|
||||||
color = (e.target as HTMLInputElement).value;
|
color = (e.target as HTMLInputElement).value;
|
||||||
onchange?.();
|
onchange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearColor() {
|
function clearColor() {
|
||||||
color = null;
|
color = null;
|
||||||
onchange?.();
|
onchange?.();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if color}
|
{#if color}
|
||||||
<IconButton
|
<IconButton onclick={clearColor} {color} {size} aria-label="Clear color" rounded="md">
|
||||||
onclick={clearColor}
|
<X class={iconSize} />
|
||||||
{color}
|
</IconButton>
|
||||||
{size}
|
{/if}
|
||||||
aria-label="Clear color"
|
<label
|
||||||
rounded="md"
|
class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
|
||||||
>
|
style={color ? `background-color: ${color};` : ''}
|
||||||
<X class={iconSize} />
|
>
|
||||||
</IconButton>
|
<Pencil
|
||||||
{/if}
|
class="{iconSize} relative z-10 pointer-events-none"
|
||||||
<label
|
style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''}
|
||||||
class="{buttonSize} flex items-center justify-center rounded-md border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
|
/>
|
||||||
style={color ? `background-color: ${color};` : ''}
|
<input
|
||||||
>
|
type="color"
|
||||||
<Pencil class="{iconSize} relative z-10 pointer-events-none" style={color ? 'color: white; filter: drop-shadow(0 0 2px rgba(0,0,0,0.5));' : ''} />
|
value={color || '#ffffff'}
|
||||||
<input
|
oninput={handleColorChange}
|
||||||
type="color"
|
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
value={color || '#ffffff'}
|
/>
|
||||||
oninput={handleColorChange}
|
</label>
|
||||||
class="absolute inset-0 opacity-0 cursor-pointer"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,133 +1,132 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { scale } from 'svelte/transition';
|
||||||
import { scale } from 'svelte/transition';
|
import { cubicOut } from 'svelte/easing';
|
||||||
import { cubicOut } from 'svelte/easing';
|
import type { Snippet } from 'svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import IconButton from './IconButton.svelte';
|
||||||
import IconButton from './IconButton.svelte';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
selectedValue,
|
selectedValue,
|
||||||
onSelect,
|
onSelect,
|
||||||
color,
|
color,
|
||||||
showCheckmark = true,
|
showCheckmark = true,
|
||||||
icon,
|
icon,
|
||||||
ariaLabel
|
ariaLabel
|
||||||
}: {
|
}: {
|
||||||
items: Array<{ value: string; label: string }>;
|
items: Array<{ value: string; label: string }>;
|
||||||
selectedValue: string;
|
selectedValue: string;
|
||||||
onSelect: (value: string) => void;
|
onSelect: (value: string) => void;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
showCheckmark?: boolean;
|
showCheckmark?: boolean;
|
||||||
icon: Snippet;
|
icon: Snippet;
|
||||||
ariaLabel: string;
|
ariaLabel: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let showMenu = $state(false);
|
let showMenu = $state(false);
|
||||||
|
|
||||||
const menuClasses = $derived(
|
const menuClasses = $derived(
|
||||||
color
|
color
|
||||||
? 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md'
|
? 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md'
|
||||||
: 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-950/90'
|
: 'absolute left-0 sm:right-0 sm:left-auto mt-2 w-40 rounded-md border shadow-lg z-50 backdrop-blur-md border-slate-200 dark:border-slate-800 bg-white/90 dark:bg-slate-950/90'
|
||||||
);
|
);
|
||||||
|
|
||||||
const menuStyle = $derived(
|
const menuStyle = $derived(
|
||||||
color
|
color
|
||||||
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
|
? `border-color: ${color}; background-color: ${color}20; backdrop-filter: blur(12px);`
|
||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
function getItemStyle(itemValue: string): string {
|
function getItemStyle(itemValue: string): string {
|
||||||
if (!color) return '';
|
if (!color) return '';
|
||||||
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
|
return selectedValue === itemValue ? `background-color: ${color}20;` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
showMenu = !showMenu;
|
showMenu = !showMenu;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelect(value: string) {
|
function handleSelect(value: string) {
|
||||||
onSelect(value);
|
onSelect(value);
|
||||||
showMenu = false;
|
showMenu = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!target.closest('.dropdown-menu')) {
|
if (!target.closest('.dropdown-menu')) {
|
||||||
showMenu = false;
|
showMenu = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseEnter(e: MouseEvent) {
|
function handleMouseEnter(e: MouseEvent) {
|
||||||
if (color) {
|
if (color) {
|
||||||
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
|
(e.currentTarget as HTMLElement).style.backgroundColor = `${color}15`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMouseLeave(e: MouseEvent, itemValue: string) {
|
function handleMouseLeave(e: MouseEvent, itemValue: string) {
|
||||||
if (color) {
|
if (color) {
|
||||||
(e.currentTarget as HTMLElement).style.backgroundColor =
|
(e.currentTarget as HTMLElement).style.backgroundColor =
|
||||||
selectedValue === itemValue ? `${color}20` : 'transparent';
|
selectedValue === itemValue ? `${color}20` : 'transparent';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showMenu) {
|
if (showMenu) {
|
||||||
document.addEventListener('click', handleClickOutside);
|
document.addEventListener('click', handleClickOutside);
|
||||||
return () => document.removeEventListener('click', handleClickOutside);
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative dropdown-menu">
|
<div class="relative dropdown-menu">
|
||||||
<IconButton
|
<IconButton
|
||||||
size="sm"
|
size="sm"
|
||||||
rounded="md"
|
rounded="md"
|
||||||
onclick={toggleMenu}
|
onclick={toggleMenu}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
class={color ? 'hover-themed' : ''}
|
class={color ? 'hover-themed' : ''}
|
||||||
style={color ? `--hover-bg: ${color}20;` : ''}
|
style={color ? `--hover-bg: ${color}20;` : ''}
|
||||||
>
|
>
|
||||||
{@render icon()}
|
{@render icon()}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{#if showMenu}
|
{#if showMenu}
|
||||||
<div
|
<div
|
||||||
class={menuClasses}
|
class={menuClasses}
|
||||||
style={menuStyle}
|
style={menuStyle}
|
||||||
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
|
transition:scale={{ duration: 150, start: 0.95, opacity: 0, easing: cubicOut }}
|
||||||
>
|
>
|
||||||
<div class="py-1">
|
<div class="py-1">
|
||||||
{#each items as item}
|
{#each items as item (item.value)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-4 py-2 text-sm transition-colors"
|
class="w-full text-left px-4 py-2 text-sm transition-colors"
|
||||||
class:hover:bg-slate-100={!color}
|
class:hover:bg-slate-100={!color}
|
||||||
class:dark:hover:bg-slate-900={!color}
|
class:dark:hover:bg-slate-900={!color}
|
||||||
class:font-bold={selectedValue === item.value}
|
class:font-bold={selectedValue === item.value}
|
||||||
class:bg-slate-100={selectedValue === item.value && !color}
|
class:bg-slate-100={selectedValue === item.value && !color}
|
||||||
class:dark:bg-slate-900={selectedValue === item.value && !color}
|
class:dark:bg-slate-900={selectedValue === item.value && !color}
|
||||||
class:flex={showCheckmark}
|
class:flex={showCheckmark}
|
||||||
class:items-center={showCheckmark}
|
class:items-center={showCheckmark}
|
||||||
class:justify-between={showCheckmark}
|
class:justify-between={showCheckmark}
|
||||||
style={getItemStyle(item.value)}
|
style={getItemStyle(item.value)}
|
||||||
onmouseenter={handleMouseEnter}
|
onmouseenter={handleMouseEnter}
|
||||||
onmouseleave={(e) => handleMouseLeave(e, item.value)}
|
onmouseleave={(e) => handleMouseLeave(e, item.value)}
|
||||||
onclick={() => handleSelect(item.value)}
|
onclick={() => handleSelect(item.value)}
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
{#if showCheckmark && selectedValue === item.value}
|
{#if showCheckmark && selectedValue === item.value}
|
||||||
<span class="ml-2">✓</span>
|
<span class="ml-2">✓</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:global(.hover-themed:hover) {
|
:global(.hover-themed:hover) {
|
||||||
background-color: var(--hover-bg) !important;
|
background-color: var(--hover-bg) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
35
src/lib/components/ui/FormField.svelte
Normal file
35
src/lib/components/ui/FormField.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
children: Snippet;
|
||||||
|
class?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
required = false,
|
||||||
|
children,
|
||||||
|
class: className = '',
|
||||||
|
description
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-2 {className}">
|
||||||
|
<Label for={id}>
|
||||||
|
{label}
|
||||||
|
{#if required}
|
||||||
|
<span class="text-muted-foreground">(required)</span>
|
||||||
|
{/if}
|
||||||
|
</Label>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-sm text-muted-foreground">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -1,52 +1,53 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
interface Props extends HTMLButtonAttributes {
|
interface Props extends HTMLButtonAttributes {
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
rounded?: 'full' | 'md' | 'lg';
|
rounded?: 'full' | 'md' | 'lg';
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
color = null,
|
color = null,
|
||||||
rounded = 'full',
|
rounded = 'full',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
class: className = '',
|
class: className = '',
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
sm: 'w-8 h-8',
|
sm: 'w-8 h-8',
|
||||||
md: 'w-10 h-10',
|
md: 'w-10 h-10',
|
||||||
lg: 'w-12 h-12'
|
lg: 'w-12 h-12'
|
||||||
};
|
};
|
||||||
|
|
||||||
const roundedClasses = {
|
const roundedClasses = {
|
||||||
full: 'rounded-full',
|
full: 'rounded-full',
|
||||||
md: 'rounded-md',
|
md: 'rounded-md',
|
||||||
lg: 'rounded-lg'
|
lg: 'rounded-lg'
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseClasses = 'flex items-center justify-center border border-input transition-colors backdrop-blur';
|
const baseClasses =
|
||||||
const sizeClass = sizeClasses[size];
|
'flex items-center justify-center border border-input transition-colors backdrop-blur';
|
||||||
const roundedClass = roundedClasses[rounded];
|
const sizeClass = sizeClasses[size];
|
||||||
|
const roundedClass = roundedClasses[rounded];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
|
class="{baseClasses} {sizeClass} {roundedClass} {className} backdrop-blur-sm"
|
||||||
class:hover:bg-accent={!color}
|
class:hover:bg-accent={!color}
|
||||||
style={color ? `--hover-bg: ${color}20;` : ''}
|
style={color ? `--hover-bg: ${color}20;` : ''}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
button[style*='--hover-bg']:hover {
|
button[style*='--hover-bg']:hover {
|
||||||
background-color: var(--hover-bg);
|
background-color: var(--hover-bg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
placeholder = languageStore.t.dashboard.searchPlaceholder
|
placeholder = languageStore.t.dashboard.searchPlaceholder
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Input
|
<Input type="search" {placeholder} bind:value />
|
||||||
type="search"
|
|
||||||
{placeholder}
|
|
||||||
bind:value
|
|
||||||
/>
|
|
||||||
|
|||||||
54
src/lib/components/ui/ThemedCard.svelte
Normal file
54
src/lib/components/ui/ThemedCard.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
|
||||||
|
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||||
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
color?: string | null;
|
||||||
|
fallbackColor?: string | null;
|
||||||
|
theme?: string | null;
|
||||||
|
showPattern?: boolean;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
class?: string;
|
||||||
|
padding?: 'none' | 'normal' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
color = null,
|
||||||
|
fallbackColor = null,
|
||||||
|
theme = null,
|
||||||
|
showPattern = false,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
class: className = '',
|
||||||
|
padding = 'normal'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const cardStyle = $derived(getCardStyle(color, fallbackColor));
|
||||||
|
|
||||||
|
const paddingClasses = {
|
||||||
|
none: 'p-0',
|
||||||
|
normal: 'p-6',
|
||||||
|
large: 'p-12'
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card style={cardStyle} class="relative overflow-hidden {className}">
|
||||||
|
<ThemeCard themeName={theme} {color} {showPattern} />
|
||||||
|
|
||||||
|
{#if title}
|
||||||
|
<CardHeader class="relative z-10">
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
{#if description}
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
{/if}
|
||||||
|
</CardHeader>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<CardContent class="{paddingClasses[padding]} relative z-10">
|
||||||
|
{@render children?.()}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Lock, LockOpen } from 'lucide-svelte';
|
import { Lock, LockOpen } from '@lucide/svelte';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
unlocked = $bindable(false)
|
unlocked = $bindable(false)
|
||||||
}: {
|
}: {
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
unlocked = !unlocked;
|
unlocked = !unlocked;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Button
|
<Button onclick={handleClick} variant={unlocked ? 'default' : 'outline'}>
|
||||||
onclick={handleClick}
|
{#if unlocked}
|
||||||
variant={unlocked ? "default" : "outline"}
|
<Lock class="mr-2 h-4 w-4" />
|
||||||
>
|
{t.wishlist.lockDeletion}
|
||||||
{#if unlocked}
|
{:else}
|
||||||
<Lock class="mr-2 h-4 w-4" />
|
<LockOpen class="mr-2 h-4 w-4" />
|
||||||
{t.wishlist.lockDeletion}
|
{t.wishlist.unlockDeletion}
|
||||||
{:else}
|
{/if}
|
||||||
<LockOpen class="mr-2 h-4 w-4" />
|
|
||||||
{t.wishlist.unlockDeletion}
|
|
||||||
{/if}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,83 +1,81 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||||
import { type VariantProps, tv } from 'tailwind-variants';
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
export const buttonVariants = tv({
|
export const buttonVariants = tv({
|
||||||
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*="size-"])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0',
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
destructive:
|
||||||
destructive:
|
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
outline:
|
||||||
outline:
|
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
secondary:
|
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
},
|
||||||
link: 'text-primary underline-offset-4 hover:underline'
|
size: {
|
||||||
},
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
size: {
|
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
icon: 'size-9',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
'icon-sm': 'size-8',
|
||||||
icon: 'size-9',
|
'icon-lg': 'size-10'
|
||||||
'icon-sm': 'size-8',
|
}
|
||||||
'icon-lg': 'size-10'
|
},
|
||||||
}
|
defaultVariants: {
|
||||||
},
|
variant: 'default',
|
||||||
defaultVariants: {
|
size: 'default'
|
||||||
variant: 'default',
|
}
|
||||||
size: 'default'
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||||
WithElementRef<HTMLAnchorAttributes> & {
|
WithElementRef<HTMLAnchorAttributes> & {
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
let {
|
||||||
class: className,
|
class: className,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
size = 'default',
|
size = 'default',
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
href = undefined,
|
href = undefined,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
disabled,
|
disabled,
|
||||||
children,
|
children,
|
||||||
...restProps
|
...restProps
|
||||||
}: ButtonProps = $props();
|
}: ButtonProps = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if href}
|
{#if href}
|
||||||
<a
|
<a
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
href={disabled ? undefined : href}
|
href={disabled ? undefined : href}
|
||||||
aria-disabled={disabled}
|
aria-disabled={disabled}
|
||||||
role={disabled ? 'link' : undefined}
|
role={disabled ? 'link' : undefined}
|
||||||
tabindex={disabled ? -1 : undefined}
|
tabindex={disabled ? -1 : undefined}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
data-slot="button"
|
data-slot="button"
|
||||||
class={cn(buttonVariants({ variant, size }), className)}
|
class={cn(buttonVariants({ variant, size }), className)}
|
||||||
{type}
|
{type}
|
||||||
{disabled}
|
{disabled}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import Root, {
|
import Root, {
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant,
|
type ButtonVariant,
|
||||||
buttonVariants
|
buttonVariants
|
||||||
} from './button.svelte';
|
} from './button.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
type ButtonProps as Props,
|
type ButtonProps as Props,
|
||||||
Root as Button,
|
Root as Button,
|
||||||
buttonVariants,
|
buttonVariants,
|
||||||
type ButtonProps,
|
type ButtonProps,
|
||||||
type ButtonSize,
|
type ButtonSize,
|
||||||
type ButtonVariant
|
type ButtonVariant
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: Props = $props();
|
let { class: className, children, ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('p-6 pt-0', className)} {...restProps}>
|
<div class={cn('p-6 pt-0', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLParagraphElement> & {
|
type Props = HTMLAttributes<HTMLParagraphElement> & {
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: Props = $props();
|
let { class: className, children, ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<p class={cn('text-sm text-muted-foreground', className)} {...restProps}>
|
<p class={cn('text-sm text-muted-foreground', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: Props = $props();
|
let { class: className, children, ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}>
|
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLHeadingElement> & {
|
type Props = HTMLAttributes<HTMLHeadingElement> & {
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: Props = $props();
|
let { class: className, children, ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h3 class={cn('font-semibold leading-none tracking-tight', className)} {...restProps}>
|
<h3 class={cn('font-semibold leading-none tracking-tight', className)} {...restProps}>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: Props = $props();
|
let { class: className, children, ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...restProps}>
|
||||||
class={cn('rounded-xl border bg-card text-card-foreground shadow', className)}
|
{@render children?.()}
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import Header from './card-header.svelte';
|
|||||||
import Title from './card-title.svelte';
|
import Title from './card-title.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
Content,
|
Content,
|
||||||
Description,
|
Description,
|
||||||
Header,
|
Header,
|
||||||
Title,
|
Title,
|
||||||
//
|
//
|
||||||
Root as Card,
|
Root as Card,
|
||||||
Content as CardContent,
|
Content as CardContent,
|
||||||
Description as CardDescription,
|
Description as CardDescription,
|
||||||
Header as CardHeader,
|
Header as CardHeader,
|
||||||
Title as CardTitle
|
Title as CardTitle
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLInputAttributes & {
|
type Props = HTMLInputAttributes & {
|
||||||
value?: string | number;
|
value?: string | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, type = 'text', value = $bindable(''), ...restProps }: Props = $props();
|
let { class: className, type = 'text', value = $bindable(''), ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type={type}
|
{type}
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:value
|
bind:value
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLLabelAttributes } from 'svelte/elements';
|
import type { HTMLLabelAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLLabelAttributes & {
|
type Props = HTMLLabelAttributes & {
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, children, ...restProps }: Props = $props();
|
let { class: className, children, ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
class={cn(
|
class={cn(
|
||||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
>
|
>
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import { languages } from '$lib/i18n/translations';
|
import { languages } from '$lib/i18n/translations';
|
||||||
import Dropdown from '$lib/components/ui/Dropdown.svelte';
|
import Dropdown from '$lib/components/ui/Dropdown.svelte';
|
||||||
import { Languages } from 'lucide-svelte';
|
import { Languages } from '@lucide/svelte';
|
||||||
|
|
||||||
let { color }: { color?: string | null } = $props();
|
let { color }: { color?: string | null } = $props();
|
||||||
|
|
||||||
const languageItems = $derived(
|
const languageItems = $derived(
|
||||||
languages.map((lang) => ({
|
languages.map((lang) => ({
|
||||||
value: lang.code,
|
value: lang.code,
|
||||||
label: lang.name
|
label: lang.name
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
function setLanguage(code: string) {
|
function setLanguage(code: string) {
|
||||||
languageStore.setLanguage(code as 'en' | 'da');
|
languageStore.setLanguage(code as 'en' | 'da');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={languageItems}
|
items={languageItems}
|
||||||
selectedValue={languageStore.current}
|
selectedValue={languageStore.current}
|
||||||
onSelect={setLanguage}
|
onSelect={setLanguage}
|
||||||
{color}
|
{color}
|
||||||
showCheckmark={false}
|
showCheckmark={false}
|
||||||
ariaLabel="Toggle language"
|
ariaLabel="Toggle language"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<Languages class="h-[1.2rem] w-[1.2rem]" />
|
<Languages class="h-[1.2rem] w-[1.2rem]" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
6
src/lib/components/ui/select/index.ts
Normal file
6
src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import Root from './select.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Root as Select
|
||||||
|
};
|
||||||
22
src/lib/components/ui/select/select.svelte
Normal file
22
src/lib/components/ui/select/select.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
type Props = HTMLSelectAttributes & {
|
||||||
|
value?: string;
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { class: className, value = $bindable(''), children, ...restProps }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<select
|
||||||
|
class={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
bind:value
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</select>
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
type Props = HTMLTextareaAttributes & {
|
type Props = HTMLTextareaAttributes & {
|
||||||
value?: string;
|
value?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { class: className, value = $bindable(''), ...restProps }: Props = $props();
|
let { class: className, value = $bindable(''), ...restProps }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
bind:value
|
bind:value
|
||||||
{...restProps}
|
{...restProps}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Dropdown from '$lib/components/ui/Dropdown.svelte';
|
import Dropdown from '$lib/components/ui/Dropdown.svelte';
|
||||||
import { Palette } from 'lucide-svelte';
|
import { Palette } from '@lucide/svelte';
|
||||||
import { AVAILABLE_THEMES } from '$lib/utils/themes';
|
import { AVAILABLE_THEMES } from '$lib/utils/themes';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = 'none',
|
value = 'none',
|
||||||
onValueChange,
|
onValueChange,
|
||||||
color
|
color
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
onValueChange: (theme: string) => void;
|
onValueChange: (theme: string) => void;
|
||||||
color?: string | null;
|
color?: string | null;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const themeItems = $derived(
|
const themeItems = $derived(
|
||||||
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
|
Object.entries(AVAILABLE_THEMES).map(([key, theme]) => ({
|
||||||
value: key,
|
value: key,
|
||||||
label: theme.name
|
label: theme.name
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
items={themeItems}
|
items={themeItems}
|
||||||
selectedValue={value}
|
selectedValue={value}
|
||||||
onSelect={onValueChange}
|
onSelect={onValueChange}
|
||||||
{color}
|
{color}
|
||||||
showCheckmark={true}
|
showCheckmark={true}
|
||||||
ariaLabel="Select theme pattern"
|
ariaLabel="Select theme pattern"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<Palette class="h-[1.2rem] w-[1.2rem]" />
|
<Palette class="h-[1.2rem] w-[1.2rem]" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { themeStore } from '$lib/stores/theme.svelte';
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
import { Sun, Moon, Monitor } from '@lucide/svelte';
|
||||||
import IconButton from '../IconButton.svelte';
|
import IconButton from '../IconButton.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
color = $bindable(null),
|
color = $bindable(null),
|
||||||
size = 'sm',
|
size = 'sm'
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
size?: 'sm' | 'md' | 'lg';
|
size?: 'sm' | 'md' | 'lg';
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
themeStore.toggle();
|
themeStore.toggle();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<IconButton onclick={toggle} {size} {color} rounded="md">
|
<IconButton onclick={toggle} {size} {color} rounded="md">
|
||||||
{#if themeStore.current === 'light'}
|
{#if themeStore.current === 'light'}
|
||||||
<Sun size={20} />
|
<Sun size={20} />
|
||||||
<span class="sr-only">Light mode (click for dark)</span>
|
<span class="sr-only">Light mode (click for dark)</span>
|
||||||
{:else if themeStore.current === 'dark'}
|
{:else if themeStore.current === 'dark'}
|
||||||
<Moon size={20} />
|
<Moon size={20} />
|
||||||
<span class="sr-only">Dark mode (click for system)</span>
|
<span class="sr-only">Dark mode (click for system)</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Monitor size={20} />
|
<Monitor size={20} />
|
||||||
<span class="sr-only">System mode (click for light)</span>
|
<span class="sr-only">System mode (click for light)</span>
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
||||||
import ImageSelector from './ImageSelector.svelte';
|
|
||||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
onSuccess?: () => void;
|
|
||||||
wishlistColor?: string | null;
|
|
||||||
wishlistTheme?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { onSuccess, wishlistColor = null, wishlistTheme = null }: Props = $props();
|
|
||||||
|
|
||||||
const cardStyle = $derived(getCardStyle(wishlistColor, null));
|
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
|
||||||
|
|
||||||
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
|
|
||||||
|
|
||||||
let linkUrl = $state('');
|
|
||||||
let imageUrl = $state('');
|
|
||||||
let color = $state<string | null>(null);
|
|
||||||
let scrapedImages = $state<string[]>([]);
|
|
||||||
let isLoadingImages = $state(false);
|
|
||||||
|
|
||||||
async function handleLinkChange(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
linkUrl = input.value;
|
|
||||||
|
|
||||||
if (linkUrl && linkUrl.startsWith('http')) {
|
|
||||||
isLoadingImages = true;
|
|
||||||
scrapedImages = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/scrape-images', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: linkUrl })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
scrapedImages = data.images || [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to scrape images:', error);
|
|
||||||
} finally {
|
|
||||||
isLoadingImages = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
|
||||||
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
|
|
||||||
<CardHeader class="relative z-10">
|
|
||||||
<CardTitle>{t.form.addNewWish}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="relative z-10">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addItem"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update({ reset: false });
|
|
||||||
onSuccess?.();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="title">{t.form.wishName} ({t.form.required})</Label>
|
|
||||||
<Input id="title" name="title" required placeholder="e.g., Blue Headphones" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="description">{t.form.description}</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
placeholder="Add details about the item..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="link">{t.form.link}</Label>
|
|
||||||
<Input
|
|
||||||
id="link"
|
|
||||||
name="link"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://..."
|
|
||||||
bind:value={linkUrl}
|
|
||||||
oninput={handleLinkChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="imageUrl">{t.form.imageUrl}</Label>
|
|
||||||
<Input
|
|
||||||
id="imageUrl"
|
|
||||||
name="imageUrl"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://..."
|
|
||||||
bind:value={imageUrl}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="price">{t.form.price}</Label>
|
|
||||||
<Input id="price" name="price" type="number" step="0.01" placeholder="0.00" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="currency">{t.form.currency}</Label>
|
|
||||||
<select
|
|
||||||
id="currency"
|
|
||||||
name="currency"
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
|
||||||
>
|
|
||||||
{#each currencies as curr}
|
|
||||||
<option value={curr} selected={curr === 'DKK'}>{curr}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Label for="color">{t.form.cardColor}</Label>
|
|
||||||
<ColorPicker bind:color={color} />
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="color" value={color || ''} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" class="w-full md:w-auto">{t.wishlist.addWish}</Button>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
@@ -1,69 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import { isLocalWishlist } from '$lib/utils/localWishlists';
|
import { isLocalWishlist } from '$lib/utils/localWishlists';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isOwner,
|
isOwner,
|
||||||
hasClaimed,
|
hasClaimed,
|
||||||
ownerToken
|
ownerToken
|
||||||
}: {
|
}: {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
hasClaimed: boolean;
|
hasClaimed: boolean;
|
||||||
ownerToken: string;
|
ownerToken: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
// Check if this wishlist is in localStorage
|
// Check if this wishlist is in localStorage
|
||||||
const isLocal = $derived(isLocalWishlist(ownerToken));
|
const isLocal = $derived(isLocalWishlist(ownerToken));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isAuthenticated}
|
{#if isAuthenticated}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
{#if isOwner}
|
{#if isOwner}
|
||||||
<Button
|
<Button disabled variant="outline" class="w-full md:w-auto opacity-60 cursor-not-allowed">
|
||||||
disabled
|
{t.wishlist.youOwnThis}
|
||||||
variant="outline"
|
</Button>
|
||||||
class="w-full md:w-auto opacity-60 cursor-not-allowed"
|
<p class="text-sm text-muted-foreground mt-2">
|
||||||
>
|
{t.wishlist.alreadyInDashboard}
|
||||||
{t.wishlist.youOwnThis}
|
</p>
|
||||||
</Button>
|
{:else}
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
<form
|
||||||
{t.wishlist.alreadyInDashboard}
|
method="POST"
|
||||||
</p>
|
action={hasClaimed ? '?/unclaimWishlist' : '?/claimWishlist'}
|
||||||
{:else}
|
use:enhance={() => {
|
||||||
<form
|
return async ({ update }) => {
|
||||||
method="POST"
|
await update({ reset: false });
|
||||||
action={hasClaimed ? "?/unclaimWishlist" : "?/claimWishlist"}
|
};
|
||||||
use:enhance={() => {
|
}}
|
||||||
return async ({ update }) => {
|
>
|
||||||
await update({ reset: false });
|
<Button type="submit" variant={hasClaimed ? 'outline' : 'default'} class="w-full md:w-auto">
|
||||||
};
|
{hasClaimed ? 'Unclaim Wishlist' : 'Claim Wishlist'}
|
||||||
}}
|
</Button>
|
||||||
>
|
</form>
|
||||||
<Button
|
<p class="text-sm text-muted-foreground mt-2">
|
||||||
type="submit"
|
{#if hasClaimed}
|
||||||
variant={hasClaimed ? "outline" : "default"}
|
You have claimed this wishlist. It will appear in your dashboard.
|
||||||
class="w-full md:w-auto"
|
{:else}
|
||||||
>
|
Claim this wishlist to add it to your dashboard for easy access.
|
||||||
{hasClaimed ? "Unclaim Wishlist" : "Claim Wishlist"}
|
{#if isLocal}
|
||||||
</Button>
|
<br />
|
||||||
</form>
|
<span class="text-xs"
|
||||||
<p class="text-sm text-muted-foreground mt-2">
|
>It will remain in your local wishlists and also appear in your claimed wishlists.</span
|
||||||
{#if hasClaimed}
|
>
|
||||||
You have claimed this wishlist. It will appear in your dashboard.
|
{/if}
|
||||||
{:else}
|
{/if}
|
||||||
Claim this wishlist to add it to your dashboard for easy access.
|
</p>
|
||||||
{#if isLocal}
|
{/if}
|
||||||
<br />
|
</div>
|
||||||
<span class="text-xs">It will remain in your local wishlists and also appear in your claimed wishlists.</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,46 +1,42 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
|
import UnlockButton from '$lib/components/ui/UnlockButton.svelte';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
unlocked = $bindable()
|
unlocked = $bindable()
|
||||||
}: {
|
}: {
|
||||||
unlocked: boolean;
|
unlocked: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mt-12 pt-8 border-t border-border space-y-4">
|
<div class="mt-12 pt-8 border-t border-border space-y-4">
|
||||||
<div class="flex flex-col md:flex-row gap-4 justify-between items-stretch md:items-center">
|
<div class="flex flex-col md:flex-row gap-4 justify-between items-stretch md:items-center">
|
||||||
<UnlockButton bind:unlocked />
|
<UnlockButton bind:unlocked />
|
||||||
|
|
||||||
{#if unlocked}
|
{#if unlocked}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteWishlist"
|
action="?/deleteWishlist"
|
||||||
use:enhance={({ cancel }) => {
|
use:enhance={({ cancel }) => {
|
||||||
if (!confirm(t.wishlist.deleteConfirm)) {
|
if (!confirm(t.wishlist.deleteConfirm)) {
|
||||||
cancel();
|
cancel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
return async ({ result }) => {
|
return async ({ result }) => {
|
||||||
if (result.type === "success") {
|
if (result.type === 'success') {
|
||||||
window.location.href = "/dashboard";
|
window.location.href = '/dashboard';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button type="submit" variant="destructive" class="w-full md:w-auto">
|
||||||
type="submit"
|
{t.wishlist.deleteWishlist}
|
||||||
variant="destructive"
|
</Button>
|
||||||
class="w-full md:w-auto"
|
</form>
|
||||||
>
|
{/if}
|
||||||
{t.wishlist.deleteWishlist}
|
</div>
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import { Input } from '$lib/components/ui/input';
|
|
||||||
import { Label } from '$lib/components/ui/label';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
||||||
import ImageSelector from './ImageSelector.svelte';
|
|
||||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
|
||||||
import { enhance } from '$app/forms';
|
|
||||||
import type { Item } from '$lib/server/schema';
|
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
item: Item;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
onColorChange?: (itemId: string, color: string) => void;
|
|
||||||
currentPosition?: number;
|
|
||||||
totalItems?: number;
|
|
||||||
onPositionChange?: (newPosition: number) => void;
|
|
||||||
wishlistColor?: string | null;
|
|
||||||
wishlistTheme?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange, wishlistColor = null, wishlistTheme = null }: Props = $props();
|
|
||||||
|
|
||||||
const cardStyle = $derived(getCardStyle(wishlistColor, null));
|
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
|
||||||
|
|
||||||
const currencies = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'];
|
|
||||||
|
|
||||||
let linkUrl = $state(item.link || '');
|
|
||||||
let imageUrl = $state(item.imageUrl || '');
|
|
||||||
let color = $state<string | null>(item.color);
|
|
||||||
let scrapedImages = $state<string[]>([]);
|
|
||||||
let isLoadingImages = $state(false);
|
|
||||||
|
|
||||||
async function handleLinkChange(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
linkUrl = input.value;
|
|
||||||
|
|
||||||
if (linkUrl && linkUrl.startsWith('http')) {
|
|
||||||
isLoadingImages = true;
|
|
||||||
scrapedImages = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/scrape-images', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ url: linkUrl })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
scrapedImages = data.images || [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to scrape images:', error);
|
|
||||||
} finally {
|
|
||||||
isLoadingImages = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
|
||||||
<ThemeCard themeName={wishlistTheme} color={wishlistColor} showPattern={false} />
|
|
||||||
<CardHeader class="relative z-10">
|
|
||||||
<CardTitle>{t.wishlist.editWish}</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="relative z-10">
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/updateItem"
|
|
||||||
use:enhance={() => {
|
|
||||||
return async ({ update }) => {
|
|
||||||
await update({ reset: false });
|
|
||||||
onSuccess?.();
|
|
||||||
};
|
|
||||||
}}
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<input type="hidden" name="itemId" value={item.id} />
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="title">{t.form.wishName} ({t.form.required})</Label>
|
|
||||||
<Input id="title" name="title" required value={item.title} placeholder="e.g., Blue Headphones" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="description">{t.form.description}</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
value={item.description || ''}
|
|
||||||
placeholder="Add details about the item..."
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="link">{t.form.link}</Label>
|
|
||||||
<Input
|
|
||||||
id="link"
|
|
||||||
name="link"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://..."
|
|
||||||
bind:value={linkUrl}
|
|
||||||
oninput={handleLinkChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="imageUrl">{t.form.imageUrl}</Label>
|
|
||||||
<Input
|
|
||||||
id="imageUrl"
|
|
||||||
name="imageUrl"
|
|
||||||
type="url"
|
|
||||||
placeholder="https://..."
|
|
||||||
bind:value={imageUrl}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ImageSelector images={scrapedImages} bind:selectedImage={imageUrl} isLoading={isLoadingImages} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="price">{t.form.price}</Label>
|
|
||||||
<Input id="price" name="price" type="number" step="0.01" value={item.price || ''} placeholder="0.00" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="currency">{t.form.currency}</Label>
|
|
||||||
<select
|
|
||||||
id="currency"
|
|
||||||
name="currency"
|
|
||||||
class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
|
|
||||||
>
|
|
||||||
{#each currencies as curr}
|
|
||||||
<option value={curr} selected={item.currency === curr}>{curr}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<Label for="color">{t.form.cardColor}</Label>
|
|
||||||
<ColorPicker bind:color={color} onchange={() => onColorChange?.(item.id, color || '')} />
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="color" value={color || ''} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2 md:col-span-2">
|
|
||||||
<Label for="position">{t.form.position}</Label>
|
|
||||||
<Input
|
|
||||||
id="position"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max={totalItems}
|
|
||||||
value={currentPosition}
|
|
||||||
onchange={(e) => {
|
|
||||||
const newPos = parseInt((e.target as HTMLInputElement).value);
|
|
||||||
if (newPos >= 1 && newPos <= totalItems) {
|
|
||||||
onPositionChange?.(newPos);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="1"
|
|
||||||
/>
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
Choose where this item appears in your wishlist (1 = top, {totalItems} = bottom)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button type="submit" class="flex-1 md:flex-none">{t.form.saveChanges}</Button>
|
|
||||||
{#if onCancel}
|
|
||||||
<Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={onCancel}>{t.form.cancel}</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
@@ -1,83 +1,67 @@
|
|||||||
<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;
|
theme?: string | null;
|
||||||
onReorder: (items: Item[]) => Promise<void>;
|
wishlistColor?: string | null;
|
||||||
theme?: string | null;
|
} = $props();
|
||||||
wishlistColor?: string | null;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
const cardStyle = $derived(getCardStyle(wishlistColor));
|
const cardStyle = $derived(getCardStyle(wishlistColor));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if items && items.length > 0}
|
{#if items && items.length > 0}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<div animate:flip={{ duration: 300 }}>
|
<div animate:flip={{ duration: 300 }}>
|
||||||
<WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}>
|
<WishlistItem {item} {theme} {wishlistColor} showDragHandle={false}>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => onStartEditing(item)}
|
onclick={() => onStartEditing(item)}
|
||||||
>
|
>
|
||||||
{t.wishlist.edit}
|
{t.wishlist.edit}
|
||||||
</Button>
|
</Button>
|
||||||
{#if rearranging}
|
{#if rearranging}
|
||||||
<form
|
<form method="POST" action="?/deleteItem" use:enhance>
|
||||||
method="POST"
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
action="?/deleteItem"
|
<Button type="submit" variant="destructive" size="sm">
|
||||||
use:enhance
|
{t.form.delete}
|
||||||
>
|
</Button>
|
||||||
<input
|
</form>
|
||||||
type="hidden"
|
{/if}
|
||||||
name="itemId"
|
</div>
|
||||||
value={item.id}
|
</WishlistItem>
|
||||||
/>
|
</div>
|
||||||
<Button
|
{/each}
|
||||||
type="submit"
|
</div>
|
||||||
variant="destructive"
|
{:else}
|
||||||
size="sm"
|
<Card style={cardStyle} class="relative overflow-hidden">
|
||||||
>
|
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
|
||||||
{t.form.delete}
|
<CardContent class="p-12 relative z-10">
|
||||||
</Button>
|
<EmptyState message={t.wishlist.noWishes + '. ' + t.wishlist.addFirstWish + '!'} />
|
||||||
</form>
|
</CardContent>
|
||||||
{/if}
|
</Card>
|
||||||
</div>
|
{/if}
|
||||||
</WishlistItem>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
|
||||||
<ThemeCard themeName={theme} color={wishlistColor} showPattern={false} />
|
|
||||||
<CardContent class="p-12 relative z-10">
|
|
||||||
<EmptyState
|
|
||||||
message={t.wishlist.noWishes + ". " + t.wishlist.addFirstWish + "!"}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
images,
|
images,
|
||||||
selectedImage = $bindable(''),
|
selectedImage = $bindable(''),
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}: {
|
}: {
|
||||||
images: string[];
|
images: string[];
|
||||||
selectedImage?: string;
|
selectedImage?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<p class="text-sm text-muted-foreground">Loading images...</p>
|
<p class="text-sm text-muted-foreground">Loading images...</p>
|
||||||
{:else if images.length > 0}
|
{:else if images.length > 0}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<Label class="text-sm">Or select from scraped images:</Label>
|
<Label class="text-sm">Or select from scraped images:</Label>
|
||||||
<div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2">
|
<div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2">
|
||||||
{#each images as imgUrl}
|
{#each images as imgUrl (imgUrl)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (selectedImage = imgUrl)}
|
onclick={() => (selectedImage = imgUrl)}
|
||||||
class="relative aspect-square rounded-md overflow-hidden border-2 hover:border-primary transition-colors"
|
class="relative aspect-square rounded-md overflow-hidden border-2 hover:border-primary transition-colors"
|
||||||
class:border-primary={selectedImage === imgUrl}
|
class:border-primary={selectedImage === imgUrl}
|
||||||
>
|
>
|
||||||
<img src={imgUrl} alt="" class="w-full h-full object-cover" />
|
<img src={imgUrl} alt="" class="w-full h-full object-cover" />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
241
src/lib/components/wishlist/ItemForm.svelte
Normal file
241
src/lib/components/wishlist/ItemForm.svelte
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
|
import { Select } from '$lib/components/ui/select';
|
||||||
|
import ThemedCard from '$lib/components/ui/ThemedCard.svelte';
|
||||||
|
import ImageSelector from './ImageSelector.svelte';
|
||||||
|
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { Item } from '$lib/db/schema';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
import { CURRENCIES } from '$lib/utils/currency';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
success: void;
|
||||||
|
cancel: void;
|
||||||
|
colorChange: { itemId: string; color: string };
|
||||||
|
positionChange: { itemId: string; newPosition: number };
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item?: Item | null;
|
||||||
|
mode: 'add' | 'edit';
|
||||||
|
itemCount?: number;
|
||||||
|
wishlistColor?: string | null;
|
||||||
|
wishlistTheme?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
item = null,
|
||||||
|
mode,
|
||||||
|
itemCount = 1,
|
||||||
|
wishlistColor = null,
|
||||||
|
wishlistTheme = null
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const isEdit = mode === 'edit' && item !== null;
|
||||||
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
|
// Form state - initialized from item if editing
|
||||||
|
let linkUrl = $state(item?.link || '');
|
||||||
|
let imageUrl = $state(item?.imageUrl || '');
|
||||||
|
let color = $state<string | null>(item?.color || null);
|
||||||
|
let position = $state(item?.order ? Number(item.order) + 1 : 1);
|
||||||
|
let scrapedImages = $state<string[]>([]);
|
||||||
|
let isLoadingImages = $state(false);
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
// Form action based on mode
|
||||||
|
const formAction = isEdit ? '?/updateItem' : '?/addItem';
|
||||||
|
const submitLabel = isEdit ? t.form.saveChanges : t.wishlist.addWish;
|
||||||
|
const titleLabel = isEdit ? t.wishlist.editWish : t.form.addNewWish;
|
||||||
|
|
||||||
|
async function scrapeImages(url: string) {
|
||||||
|
if (!url || !url.startsWith('http')) return;
|
||||||
|
|
||||||
|
isLoadingImages = true;
|
||||||
|
scrapedImages = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/scrape-images', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
scrapedImages = data.images || [];
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to scrape images:', error);
|
||||||
|
} finally {
|
||||||
|
isLoadingImages = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLinkInput(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
linkUrl = input.value;
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timer to scrape after user stops typing for 500ms
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
scrapeImages(linkUrl);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorChange() {
|
||||||
|
if (isEdit && item) {
|
||||||
|
dispatch('colorChange', { itemId: item.id, color: color || '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePositionChange() {
|
||||||
|
if (isEdit && item) {
|
||||||
|
dispatch('positionChange', { itemId: item.id, newPosition: position });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load images when form opens with existing URL
|
||||||
|
$effect(() => {
|
||||||
|
if (linkUrl && linkUrl.startsWith('http')) {
|
||||||
|
scrapeImages(linkUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ThemedCard theme={wishlistTheme} color={wishlistColor} title={titleLabel}>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action={formAction}
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
dispatch('success');
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
{#if isEdit && item}
|
||||||
|
<input type="hidden" name="itemId" value={item.id} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2 md:col-span-2">
|
||||||
|
<Label for="title">{t.form.wishName} ({t.form.required})</Label>
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
value={item?.title || ''}
|
||||||
|
placeholder="e.g., Blue Headphones"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 md:col-span-2">
|
||||||
|
<Label for="description">{t.form.description}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
value={item?.description || ''}
|
||||||
|
placeholder="Add details about the item..."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 md:col-span-2">
|
||||||
|
<Label for="link">{t.form.link}</Label>
|
||||||
|
<Input
|
||||||
|
id="link"
|
||||||
|
name="link"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
bind:value={linkUrl}
|
||||||
|
oninput={handleLinkInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 md:col-span-2">
|
||||||
|
<Label for="imageUrl">{t.form.imageUrl}</Label>
|
||||||
|
<Input
|
||||||
|
id="imageUrl"
|
||||||
|
name="imageUrl"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://..."
|
||||||
|
bind:value={imageUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageSelector
|
||||||
|
images={scrapedImages}
|
||||||
|
bind:selectedImage={imageUrl}
|
||||||
|
isLoading={isLoadingImages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="price">{t.form.price}</Label>
|
||||||
|
<Input
|
||||||
|
id="price"
|
||||||
|
name="price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={item?.price || ''}
|
||||||
|
placeholder="0.00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 md:col-span-2">
|
||||||
|
<Label for="currency">{t.form.currency}</Label>
|
||||||
|
<Select id="currency" name="currency" value={item?.currency || 'DKK'}>
|
||||||
|
{#each CURRENCIES as curr (curr)}
|
||||||
|
<option value={curr}>{curr}</option>
|
||||||
|
{/each}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="color">{t.form.cardColor}</Label>
|
||||||
|
<ColorPicker bind:color onchange={handleColorChange} />
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="color" value={color || ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isEdit}
|
||||||
|
<div class="space-y-2 md:col-span-2">
|
||||||
|
<Label for="position">{t.form.position}</Label>
|
||||||
|
<Input
|
||||||
|
id="position"
|
||||||
|
name="position"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={itemCount}
|
||||||
|
bind:value={position}
|
||||||
|
onchange={handlePositionChange}
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Choose where this item appears in your wishlist (1 = top, {itemCount} = bottom)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="submit" class="flex-1 md:flex-none">{submitLabel}</Button>
|
||||||
|
{#if isEdit}
|
||||||
|
<Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={() => dispatch('cancel')}>
|
||||||
|
{t.form.cancel}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ThemedCard>
|
||||||
@@ -1,123 +1,121 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
isReserved: boolean;
|
isReserved: boolean;
|
||||||
reserverName?: string | null;
|
reserverName?: string | null;
|
||||||
reservationUserId?: string | null;
|
reservationUserId?: string | null;
|
||||||
currentUserId?: string | null;
|
currentUserId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { itemId, isReserved, reserverName, reservationUserId, currentUserId }: Props = $props();
|
let { itemId, isReserved, reserverName, reservationUserId, currentUserId }: Props = $props();
|
||||||
|
|
||||||
let showReserveForm = $state(false);
|
let showReserveForm = $state(false);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let showCancelConfirmation = $state(false);
|
let showCancelConfirmation = $state(false);
|
||||||
|
|
||||||
const canCancel = $derived(() => {
|
const canCancel = $derived(() => {
|
||||||
if (!isReserved) return false;
|
if (!isReserved) return false;
|
||||||
if (reservationUserId) {
|
if (reservationUserId) {
|
||||||
return currentUserId === reservationUserId;
|
return currentUserId === reservationUserId;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAnonymousReservation = $derived(!reservationUserId);
|
const isAnonymousReservation = $derived(!reservationUserId);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isReserved}
|
{#if isReserved}
|
||||||
<div class="flex flex-col items-start gap-2">
|
<div class="flex flex-col items-start gap-2">
|
||||||
<div class="text-sm text-green-600 font-medium">
|
<div class="text-sm text-green-600 font-medium">
|
||||||
✓ Reserved
|
✓ Reserved
|
||||||
{#if reserverName}
|
{#if reserverName}
|
||||||
by {reserverName}
|
by {reserverName}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if canCancel()}
|
{#if canCancel()}
|
||||||
{#if showCancelConfirmation}
|
{#if showCancelConfirmation}
|
||||||
<div class="flex flex-col gap-2 items-start">
|
<div class="flex flex-col gap-2 items-start">
|
||||||
<p class="text-sm text-muted-foreground">
|
<p class="text-sm text-muted-foreground">Cancel this reservation?</p>
|
||||||
Cancel this reservation?
|
<div class="flex gap-2">
|
||||||
</p>
|
<form
|
||||||
<div class="flex gap-2">
|
method="POST"
|
||||||
<form method="POST" action="?/unreserve" use:enhance={() => {
|
action="?/unreserve"
|
||||||
return async ({ update }) => {
|
use:enhance={() => {
|
||||||
showCancelConfirmation = false;
|
return async ({ update }) => {
|
||||||
await update();
|
showCancelConfirmation = false;
|
||||||
};
|
await update();
|
||||||
}}>
|
};
|
||||||
<input type="hidden" name="itemId" value={itemId} />
|
}}
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
>
|
||||||
Yes, Cancel
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
</Button>
|
<Button type="submit" variant="destructive" size="sm">Yes, Cancel</Button>
|
||||||
</form>
|
</form>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => (showCancelConfirmation = false)}
|
onclick={() => (showCancelConfirmation = false)}
|
||||||
>
|
>
|
||||||
No, Keep It
|
No, Keep It
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if isAnonymousReservation}
|
{:else if isAnonymousReservation}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => (showCancelConfirmation = true)}
|
onclick={() => (showCancelConfirmation = true)}
|
||||||
>
|
>
|
||||||
Cancel Reservation
|
Cancel Reservation
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<form method="POST" action="?/unreserve" use:enhance>
|
<form method="POST" action="?/unreserve" use:enhance>
|
||||||
<input type="hidden" name="itemId" value={itemId} />
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
<Button type="submit" variant="outline" size="sm">
|
<Button type="submit" variant="outline" size="sm">Cancel Reservation</Button>
|
||||||
Cancel Reservation
|
</form>
|
||||||
</Button>
|
{/if}
|
||||||
</form>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if showReserveForm}
|
{:else if showReserveForm}
|
||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/reserve"
|
action="?/reserve"
|
||||||
use:enhance={() => {
|
use:enhance={() => {
|
||||||
return async ({ update }) => {
|
return async ({ update }) => {
|
||||||
await update();
|
await update();
|
||||||
showReserveForm = false;
|
showReserveForm = false;
|
||||||
name = '';
|
name = '';
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
class="flex flex-col gap-2 w-full md:w-auto"
|
class="flex flex-col gap-2 w-full md:w-auto"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="itemId" value={itemId} />
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
<Input
|
<Input
|
||||||
name="reserverName"
|
name="reserverName"
|
||||||
placeholder="Your name (optional)"
|
placeholder="Your name (optional)"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
class="w-full md:w-48"
|
class="w-full md:w-48"
|
||||||
/>
|
/>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button type="submit" size="sm" class="flex-1">Confirm</Button>
|
<Button type="submit" size="sm" class="flex-1">Confirm</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => (showReserveForm = false)}
|
onclick={() => (showReserveForm = false)}
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{:else}
|
{:else}
|
||||||
<Button onclick={() => (showReserveForm = true)} size="sm" class="w-full md:w-auto">
|
<Button onclick={() => (showReserveForm = true)} size="sm" class="w-full md:w-auto">
|
||||||
Reserve This
|
Reserve This
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,64 +1,66 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Card, CardContent } from '$lib/components/ui/card';
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
ownerUrl?: string;
|
ownerUrl?: string;
|
||||||
wishlistColor?: string | null;
|
wishlistColor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props();
|
let { publicUrl, ownerUrl, wishlistColor = null }: Props = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
const cardStyle = $derived(getCardStyle(null, wishlistColor));
|
const cardStyle = $derived(getCardStyle(null, wishlistColor));
|
||||||
|
|
||||||
let copiedPublic = $state(false);
|
let copiedPublic = $state(false);
|
||||||
let copiedOwner = $state(false);
|
let copiedOwner = $state(false);
|
||||||
|
|
||||||
const publicLink = $derived(
|
const publicLink = $derived(
|
||||||
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
|
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
|
||||||
);
|
);
|
||||||
const ownerLink = $derived(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : '');
|
const ownerLink = $derived(
|
||||||
|
ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : ''
|
||||||
|
);
|
||||||
|
|
||||||
async function copyToClipboard(text: string, type: 'public' | 'owner') {
|
async function copyToClipboard(text: string, type: 'public' | 'owner') {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
if (type === 'public') {
|
if (type === 'public') {
|
||||||
copiedPublic = true;
|
copiedPublic = true;
|
||||||
setTimeout(() => (copiedPublic = false), 2000);
|
setTimeout(() => (copiedPublic = false), 2000);
|
||||||
} else {
|
} else {
|
||||||
copiedOwner = true;
|
copiedOwner = true;
|
||||||
setTimeout(() => (copiedOwner = false), 2000);
|
setTimeout(() => (copiedOwner = false), 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle}>
|
<Card style={cardStyle}>
|
||||||
<CardContent class="space-y-4 pt-6">
|
<CardContent class="space-y-4 pt-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>{t.wishlist.shareViewOnly}</Label>
|
<Label>{t.wishlist.shareViewOnly}</Label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Input readonly value={publicLink} class="font-mono text-sm" />
|
<Input readonly value={publicLink} class="font-mono text-sm" />
|
||||||
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
|
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
|
||||||
{copiedPublic ? t.wishlist.copied : t.wishlist.copy}
|
{copiedPublic ? t.wishlist.copied : t.wishlist.copy}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if ownerLink}
|
{#if ownerLink}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label>{t.wishlist.shareEditLink}</Label>
|
<Label>{t.wishlist.shareEditLink}</Label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Input readonly value={ownerLink} class="font-mono text-sm" />
|
<Input readonly value={ownerLink} class="font-mono text-sm" />
|
||||||
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
|
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
|
||||||
{copiedOwner ? t.wishlist.copied : t.wishlist.copy}
|
{copiedOwner ? t.wishlist.copied : t.wishlist.copy}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
rearranging = $bindable(false),
|
rearranging = $bindable(false),
|
||||||
showAddForm = false,
|
showAddForm = false,
|
||||||
onToggleAddForm
|
onToggleAddForm
|
||||||
}: {
|
}: {
|
||||||
rearranging: boolean;
|
rearranging: boolean;
|
||||||
showAddForm?: boolean;
|
showAddForm?: boolean;
|
||||||
onToggleAddForm: () => void;
|
onToggleAddForm: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-4">
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
<Button
|
<Button onclick={onToggleAddForm} class="w-full md:w-auto">
|
||||||
onclick={onToggleAddForm}
|
{showAddForm ? t.form.cancel : t.wishlist.addWish}
|
||||||
class="w-full md:w-auto"
|
</Button>
|
||||||
>
|
|
||||||
{showAddForm ? t.form.cancel : t.wishlist.addWish}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,212 +1,212 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import { Card, CardContent } from '$lib/components/ui/card';
|
||||||
import { Input } from "$lib/components/ui/input";
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from "$lib/components/ui/label";
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Textarea } from "$lib/components/ui/textarea";
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import { Pencil, Check, X } from "lucide-svelte";
|
import { Pencil, Check, X } from '@lucide/svelte';
|
||||||
import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
|
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||||
import ThemePicker from "$lib/components/ui/theme-picker.svelte";
|
import ThemePicker from '$lib/components/ui/theme-picker.svelte';
|
||||||
import IconButton from "$lib/components/ui/IconButton.svelte";
|
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||||
import type { Wishlist } from "$lib/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';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
wishlist,
|
wishlist,
|
||||||
onTitleUpdate,
|
onTitleUpdate,
|
||||||
onDescriptionUpdate,
|
onDescriptionUpdate,
|
||||||
onColorUpdate,
|
onColorUpdate,
|
||||||
onEndDateUpdate,
|
onEndDateUpdate,
|
||||||
onThemeUpdate
|
onThemeUpdate
|
||||||
}: {
|
}: {
|
||||||
wishlist: Wishlist;
|
wishlist: Wishlist;
|
||||||
onTitleUpdate: (title: string) => Promise<boolean>;
|
onTitleUpdate: (title: string) => Promise<boolean>;
|
||||||
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
|
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
|
||||||
onColorUpdate: (color: string | null) => void;
|
onColorUpdate: (color: string | null) => void;
|
||||||
onEndDateUpdate: (endDate: string | null) => void;
|
onEndDateUpdate: (endDate: string | null) => void;
|
||||||
onThemeUpdate: (theme: string | null) => void;
|
onThemeUpdate: (theme: string | null) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
let editingTitle = $state(false);
|
let editingTitle = $state(false);
|
||||||
let editingDescription = $state(false);
|
let editingDescription = $state(false);
|
||||||
let wishlistTitle = $state(wishlist.title);
|
let wishlistTitle = $state(wishlist.title);
|
||||||
let wishlistDescription = $state(wishlist.description || "");
|
let wishlistDescription = $state(wishlist.description || '');
|
||||||
let wishlistColor = $state<string | null>(wishlist.color);
|
let wishlistColor = $state<string | null>(wishlist.color);
|
||||||
let wishlistTheme = $state<string>(wishlist.theme || 'none');
|
let wishlistTheme = $state<string>(wishlist.theme || 'none');
|
||||||
let wishlistEndDate = $state<string | null>(
|
let wishlistEndDate = $state<string | null>(
|
||||||
wishlist.endDate
|
wishlist.endDate ? new Date(wishlist.endDate).toISOString().split('T')[0] : null
|
||||||
? new Date(wishlist.endDate).toISOString().split("T")[0]
|
);
|
||||||
: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const cardStyle = $derived(getCardStyle(null, wishlistColor));
|
const cardStyle = $derived(getCardStyle(null, wishlistColor));
|
||||||
|
|
||||||
async function saveTitle() {
|
async function saveTitle() {
|
||||||
if (!wishlistTitle.trim()) {
|
if (!wishlistTitle.trim()) {
|
||||||
wishlistTitle = wishlist.title;
|
wishlistTitle = wishlist.title;
|
||||||
editingTitle = false;
|
editingTitle = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = await onTitleUpdate(wishlistTitle.trim());
|
const success = await onTitleUpdate(wishlistTitle.trim());
|
||||||
if (success) {
|
if (success) {
|
||||||
editingTitle = false;
|
editingTitle = false;
|
||||||
} else {
|
} else {
|
||||||
wishlistTitle = wishlist.title;
|
wishlistTitle = wishlist.title;
|
||||||
editingTitle = false;
|
editingTitle = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveDescription() {
|
async function saveDescription() {
|
||||||
const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
|
const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
|
||||||
if (success) {
|
if (success) {
|
||||||
editingDescription = false;
|
editingDescription = false;
|
||||||
} else {
|
} else {
|
||||||
wishlistDescription = wishlist.description || "";
|
wishlistDescription = wishlist.description || '';
|
||||||
editingDescription = false;
|
editingDescription = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEndDateChange(e: Event) {
|
function handleEndDateChange(e: Event) {
|
||||||
const input = e.target as HTMLInputElement;
|
const input = e.target as HTMLInputElement;
|
||||||
wishlistEndDate = input.value || null;
|
wishlistEndDate = input.value || null;
|
||||||
onEndDateUpdate(wishlistEndDate);
|
onEndDateUpdate(wishlistEndDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearEndDate() {
|
function clearEndDate() {
|
||||||
wishlistEndDate = null;
|
wishlistEndDate = null;
|
||||||
onEndDateUpdate(null);
|
onEndDateUpdate(null);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 mb-6">
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{#if editingTitle}
|
{#if editingTitle}
|
||||||
<Input
|
<Input
|
||||||
bind:value={wishlistTitle}
|
bind:value={wishlistTitle}
|
||||||
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
|
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === 'Enter') {
|
||||||
saveTitle();
|
saveTitle();
|
||||||
} else if (e.key === "Escape") {
|
} else if (e.key === 'Escape') {
|
||||||
wishlistTitle = wishlist.title;
|
wishlistTitle = wishlist.title;
|
||||||
editingTitle = false;
|
editingTitle = false;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
|
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
|
||||||
{/if}
|
{/if}
|
||||||
<IconButton
|
<IconButton
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (editingTitle) {
|
if (editingTitle) {
|
||||||
saveTitle();
|
saveTitle();
|
||||||
} else {
|
} else {
|
||||||
editingTitle = true;
|
editingTitle = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
color={wishlistColor}
|
color={wishlistColor}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
aria-label={editingTitle ? "Save title" : "Edit title"}
|
aria-label={editingTitle ? 'Save title' : 'Edit title'}
|
||||||
>
|
>
|
||||||
{#if editingTitle}
|
{#if editingTitle}
|
||||||
<Check class="w-4 h-4" />
|
<Check class="w-4 h-4" />
|
||||||
{:else}
|
{:else}
|
||||||
<Pencil class="w-4 h-4" />
|
<Pencil class="w-4 h-4" />
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<ThemePicker
|
<ThemePicker
|
||||||
value={wishlistTheme}
|
value={wishlistTheme}
|
||||||
onValueChange={async (theme) => {
|
onValueChange={async (theme) => {
|
||||||
wishlistTheme = theme;
|
wishlistTheme = theme;
|
||||||
onThemeUpdate(theme);
|
onThemeUpdate(theme);
|
||||||
// Force reactivity by updating the wishlist object
|
// Force reactivity by updating the wishlist object
|
||||||
wishlist.theme = theme;
|
wishlist.theme = theme;
|
||||||
}}
|
}}
|
||||||
color={wishlistColor}
|
color={wishlistColor}
|
||||||
/>
|
/>
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
bind:color={wishlistColor}
|
bind:color={wishlistColor}
|
||||||
onchange={() => onColorUpdate(wishlistColor)}
|
onchange={() => onColorUpdate(wishlistColor)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card style={cardStyle}>
|
<Card style={cardStyle}>
|
||||||
<CardContent class="pt-6 space-y-4">
|
<CardContent class="pt-6 space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label for="wishlist-description">{t.form.descriptionOptional}</Label>
|
<Label for="wishlist-description">{t.form.descriptionOptional}</Label>
|
||||||
<IconButton
|
<IconButton
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (editingDescription) {
|
if (editingDescription) {
|
||||||
saveDescription();
|
saveDescription();
|
||||||
} else {
|
} else {
|
||||||
editingDescription = true;
|
editingDescription = true;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
color={wishlistColor}
|
color={wishlistColor}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
aria-label={editingDescription ? "Save description" : "Edit description"}
|
aria-label={editingDescription ? 'Save description' : 'Edit description'}
|
||||||
>
|
>
|
||||||
{#if editingDescription}
|
{#if editingDescription}
|
||||||
<Check class="w-4 h-4" />
|
<Check class="w-4 h-4" />
|
||||||
{:else}
|
{:else}
|
||||||
<Pencil class="w-4 h-4" />
|
<Pencil class="w-4 h-4" />
|
||||||
{/if}
|
{/if}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
{#if editingDescription}
|
{#if editingDescription}
|
||||||
<Textarea
|
<Textarea
|
||||||
id="wishlist-description"
|
id="wishlist-description"
|
||||||
bind:value={wishlistDescription}
|
bind:value={wishlistDescription}
|
||||||
class="w-full"
|
class="w-full"
|
||||||
rows={3}
|
rows={3}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Escape") {
|
if (e.key === 'Escape') {
|
||||||
wishlistDescription = wishlist.description || "";
|
wishlistDescription = wishlist.description || '';
|
||||||
editingDescription = false;
|
editingDescription = false;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]">
|
<div
|
||||||
{wishlistDescription || t.form.noDescription}
|
class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]"
|
||||||
</div>
|
>
|
||||||
{/if}
|
{wishlistDescription || t.form.noDescription}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||||
<Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
|
<Label for="wishlist-end-date">{t.form.endDateOptional}</Label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{#if wishlistEndDate}
|
{#if wishlistEndDate}
|
||||||
<IconButton
|
<IconButton
|
||||||
onclick={clearEndDate}
|
onclick={clearEndDate}
|
||||||
color={wishlistColor}
|
color={wishlistColor}
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex-shrink-0"
|
class="flex-shrink-0"
|
||||||
aria-label="Clear end date"
|
aria-label="Clear end date"
|
||||||
>
|
>
|
||||||
<X class="w-4 h-4" />
|
<X class="w-4 h-4" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
{/if}
|
{/if}
|
||||||
<Input
|
<Input
|
||||||
id="wishlist-end-date"
|
id="wishlist-end-date"
|
||||||
type="date"
|
type="date"
|
||||||
value={wishlistEndDate || ""}
|
value={wishlistEndDate || ''}
|
||||||
onchange={handleEndDateChange}
|
onchange={handleEndDateChange}
|
||||||
class="w-full sm:w-auto"
|
class="w-full sm:w-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,125 +1,96 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Card, CardContent } from "$lib/components/ui/card";
|
import ThemedCard from '$lib/components/ui/ThemedCard.svelte';
|
||||||
import type { Item } from "$lib/server/schema";
|
import type { Item } from '$lib/db/schema';
|
||||||
import { GripVertical, ExternalLink } from "lucide-svelte";
|
import { GripVertical, ExternalLink } from '@lucide/svelte';
|
||||||
import { getCardStyle } from '$lib/utils/colors';
|
import { 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 { formatPrice } from '$lib/utils/currency';
|
||||||
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: Item;
|
item: Item;
|
||||||
showImage?: boolean;
|
showImage?: boolean;
|
||||||
children?: any;
|
children?: import('svelte').Snippet;
|
||||||
showDragHandle?: boolean;
|
showDragHandle?: boolean;
|
||||||
theme?: string | null;
|
theme?: string | null;
|
||||||
wishlistColor?: string | null;
|
wishlistColor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
item,
|
item,
|
||||||
showImage = true,
|
showImage = true,
|
||||||
children,
|
children,
|
||||||
showDragHandle = false,
|
showDragHandle = false,
|
||||||
theme = null,
|
theme = null,
|
||||||
wishlistColor = null
|
wishlistColor = null
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
const currencySymbols: Record<string, string> = {
|
|
||||||
DKK: "kr",
|
|
||||||
EUR: "€",
|
|
||||||
USD: "$",
|
|
||||||
SEK: "kr",
|
|
||||||
NOK: "kr",
|
|
||||||
GBP: "£",
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatPrice(
|
|
||||||
price: string | null,
|
|
||||||
currency: string | null,
|
|
||||||
): string {
|
|
||||||
if (!price) return "";
|
|
||||||
const symbol = currency ? currencySymbols[currency] || currency : "kr";
|
|
||||||
const amount = parseFloat(price).toFixed(2);
|
|
||||||
|
|
||||||
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
|
|
||||||
if (currency && ["DKK", "SEK", "NOK"].includes(currency)) {
|
|
||||||
return `${amount} ${symbol}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For other currencies, put symbol before
|
|
||||||
return `${symbol}${amount}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cardStyle = $derived(getCardStyle(item.color, wishlistColor));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card style={cardStyle} class="relative overflow-hidden">
|
<ThemedCard color={item.color} fallbackColor={wishlistColor} theme={theme}>
|
||||||
<ThemeCard themeName={theme} color={item.color} showPattern={false} />
|
<div class="flex gap-4">
|
||||||
<CardContent class="p-6 relative z-10">
|
{#if showDragHandle}
|
||||||
<div class="flex gap-4">
|
<div
|
||||||
{#if showDragHandle}
|
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
|
||||||
<div
|
aria-label="Drag to reorder"
|
||||||
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
|
role="button"
|
||||||
aria-label="Drag to reorder"
|
tabindex="0"
|
||||||
role="button"
|
style="touch-action: none;"
|
||||||
tabindex="0"
|
>
|
||||||
style="touch-action: none;"
|
<GripVertical class="w-6 h-6 text-muted-foreground" />
|
||||||
>
|
</div>
|
||||||
<GripVertical class="w-6 h-6 text-muted-foreground" />
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-4 flex-1">
|
<div class="flex flex-col md:flex-row gap-4 flex-1">
|
||||||
{#if showImage && item.imageUrl}
|
{#if showImage && item.imageUrl}
|
||||||
<img
|
<img
|
||||||
src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}"
|
src="/api/image-proxy?url={encodeURIComponent(item.imageUrl)}"
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
class="w-full md:w-32 h-32 object-cover rounded-lg"
|
class="w-full md:w-32 h-32 object-cover rounded-lg"
|
||||||
onerror={(e) => e.currentTarget.src = item.imageUrl}
|
onerror={(e: Event) => ((e.currentTarget as HTMLImageElement).src = item.imageUrl)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="flex-1 items-center min-w-0">
|
<div class="flex-1 items-center min-w-0">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h3 class="font-semibold text-lg break-words">{item.title}</h3>
|
<h3 class="font-semibold text-lg break-words">{item.title}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if item.description}
|
{#if item.description}
|
||||||
<p class="text-muted-foreground break-words whitespace-pre-wrap" style="overflow-wrap: anywhere;">{item.description}</p>
|
<p
|
||||||
{/if}
|
class="text-muted-foreground break-words whitespace-pre-wrap"
|
||||||
|
style="overflow-wrap: anywhere;"
|
||||||
|
>
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 items-center text-sm mt-2">
|
<div class="flex flex-wrap gap-2 items-center text-sm mt-2">
|
||||||
{#if item.price}
|
{#if item.price}
|
||||||
<span class="font-medium"
|
<span class="font-medium">{formatPrice(item.price, item.currency)}</span>
|
||||||
>{formatPrice(item.price, item.currency)}</span
|
{/if}
|
||||||
>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 items-center mt-3">
|
<div class="flex flex-wrap gap-2 items-center mt-3">
|
||||||
{#if item.link}
|
{#if item.link}
|
||||||
<Button
|
<Button
|
||||||
href={item.link}
|
href={item.link}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="gap-1.5"
|
class="gap-1.5"
|
||||||
>
|
>
|
||||||
<ExternalLink class="w-4 h-4" />
|
<ExternalLink class="w-4 h-4" />
|
||||||
{t.wishlist.viewProduct}
|
{t.wishlist.viewProduct}
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</ThemedCard>
|
||||||
</Card>
|
|
||||||
|
|||||||
193
src/lib/db/schema.ts
Normal file
193
src/lib/db/schema.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
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;
|
||||||
@@ -2,162 +2,166 @@ import type { Translation } from './en';
|
|||||||
|
|
||||||
// Danish translations
|
// Danish translations
|
||||||
export const da: Translation = {
|
export const da: Translation = {
|
||||||
// Navigation
|
// Navigation
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Dashboard'
|
dashboard: 'Dashboard'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashboard: {
|
dashboard: {
|
||||||
myWishlists: 'Mine Ønskelister',
|
myWishlists: 'Mine Ønskelister',
|
||||||
myWishlistsDescription: 'Ønskelister du ejer og administrerer',
|
myWishlistsDescription: 'Ønskelister du ejer og administrerer',
|
||||||
claimedWishlists: 'Ejede Ønskelister',
|
claimedWishlists: 'Ejede Ønskelister',
|
||||||
claimedWishlistsDescription: 'Ønskelister du har taget ejerskab af og kan redigere',
|
claimedWishlistsDescription: 'Ønskelister du har taget ejerskab af og kan redigere',
|
||||||
savedWishlists: 'Gemte Ønskelister',
|
savedWishlists: 'Gemte Ønskelister',
|
||||||
savedWishlistsDescription: 'Ønskelister du følger',
|
savedWishlistsDescription: 'Ønskelister du følger',
|
||||||
createNew: '+ Opret Ny',
|
createNew: '+ Opret Ny',
|
||||||
manage: 'Administrer',
|
manage: 'Administrer',
|
||||||
copyLink: 'Kopiér Link',
|
copyLink: 'Kopiér Link',
|
||||||
viewWishlist: 'Se Ønskeliste',
|
viewWishlist: 'Se Ønskeliste',
|
||||||
unsave: 'Fjern',
|
unsave: 'Fjern',
|
||||||
unclaim: 'Fjern Ejerskab',
|
unclaim: 'Fjern Ejerskab',
|
||||||
delete: 'Slet',
|
delete: 'Slet',
|
||||||
emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.',
|
emptyWishlists: 'Du har ikke oprettet nogen ønskelister endnu.',
|
||||||
emptyWishlistsAction: 'Opret Din Første Ønskeliste',
|
emptyWishlistsAction: 'Opret Din Første Ønskeliste',
|
||||||
emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.',
|
emptyClaimedWishlists: 'Du har ikke taget ejerskab af nogen ønskelister endnu.',
|
||||||
emptyClaimedWishlistsDescription: 'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
|
emptyClaimedWishlistsDescription:
|
||||||
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
|
'Når nogen deler et redigeringslink med dig, kan du tage ejerskab af det for at administrere det fra dit dashboard.',
|
||||||
emptySavedWishlistsDescription: 'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
|
emptySavedWishlists: 'Du har ikke gemt nogen ønskelister endnu.',
|
||||||
by: 'af',
|
emptySavedWishlistsDescription:
|
||||||
ends: 'Slutter',
|
'Når du ser en andens ønskeliste, kan du gemme den for nemt at finde den senere.',
|
||||||
welcomeBack: 'Velkommen tilbage',
|
by: 'af',
|
||||||
searchPlaceholder: 'Søg ønsker...'
|
ends: 'Slutter',
|
||||||
},
|
welcomeBack: 'Velkommen tilbage',
|
||||||
|
searchPlaceholder: 'Søg ønsker...'
|
||||||
|
},
|
||||||
|
|
||||||
// Wishlist
|
// Wishlist
|
||||||
wishlist: {
|
wishlist: {
|
||||||
title: 'Ønskeliste',
|
title: 'Ønskeliste',
|
||||||
createTitle: 'Opret Din Ønskeliste',
|
createTitle: 'Opret Din Ønskeliste',
|
||||||
createDescription: 'Opret en ønskeliste og del den med venner og familie',
|
createDescription: 'Opret en ønskeliste og del den med venner og familie',
|
||||||
addWish: '+ Tilføj Ønske',
|
addWish: '+ Tilføj Ønske',
|
||||||
editWish: 'Rediger Ønske',
|
editWish: 'Rediger Ønske',
|
||||||
deleteWish: 'Slet Ønske',
|
deleteWish: 'Slet Ønske',
|
||||||
reserve: 'Reservér',
|
reserve: 'Reservér',
|
||||||
unreserve: 'Fjern Reservation',
|
unreserve: 'Fjern Reservation',
|
||||||
reserved: 'Reserveret',
|
reserved: 'Reserveret',
|
||||||
reservedBy: 'af',
|
reservedBy: 'af',
|
||||||
save: 'Gem',
|
save: 'Gem',
|
||||||
saveWishlist: 'Gem Ønskeliste',
|
saveWishlist: 'Gem Ønskeliste',
|
||||||
unsaveWishlist: 'Fjern',
|
unsaveWishlist: 'Fjern',
|
||||||
share: 'Del',
|
share: 'Del',
|
||||||
edit: 'Rediger',
|
edit: 'Rediger',
|
||||||
back: 'Tilbage',
|
back: 'Tilbage',
|
||||||
noWishes: 'Ingen ønsker endnu',
|
noWishes: 'Ingen ønsker endnu',
|
||||||
addFirstWish: 'Tilføj dit første ønske',
|
addFirstWish: 'Tilføj dit første ønske',
|
||||||
emptyWishes: 'Denne ønskeliste har ingen ønsker endnu.',
|
emptyWishes: 'Denne ønskeliste har ingen ønsker endnu.',
|
||||||
viewProduct: 'Se Produkt',
|
viewProduct: 'Se Produkt',
|
||||||
claimWishlist: 'Tag Ejerskab Af Ønskeliste',
|
claimWishlist: 'Tag Ejerskab Af Ønskeliste',
|
||||||
unclaimWishlist: 'Fjern Ejerskab Af Ønskeliste',
|
unclaimWishlist: 'Fjern Ejerskab Af Ønskeliste',
|
||||||
youOwnThis: 'Du Ejer Denne Ønskeliste',
|
youOwnThis: 'Du Ejer Denne Ønskeliste',
|
||||||
youClaimedThis: 'Du Har Taget Ejerskab Af Denne Ønskeliste',
|
youClaimedThis: 'Du Har Taget Ejerskab Af Denne Ønskeliste',
|
||||||
alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.',
|
alreadyInDashboard: 'Denne ønskeliste er allerede it dit dashboard.',
|
||||||
alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.',
|
alreadyClaimed: 'Denne ønskeliste er allerede i dit dashboard som en ejet ønskeliste.',
|
||||||
claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard',
|
claimDescription: 'Tag ejerskab af denne ønskeliste for at tilføje den til dit dashboard',
|
||||||
claimedDescription: 'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
|
claimedDescription:
|
||||||
deleteWishlist: 'Slet Ønskeliste',
|
'Du har taget ejerskab af denne ønskeliste og kan tilgå den fra dit dashboard',
|
||||||
deleteConfirm: 'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
|
deleteWishlist: 'Slet Ønskeliste',
|
||||||
lockDeletion: 'Lås Sletning',
|
deleteConfirm:
|
||||||
unlockDeletion: 'Lås Op for Sletning',
|
'Er du sikker på, at du vil slette denne ønskeliste? Denne handling kan ikke fortrydes.',
|
||||||
shareViewOnly: 'Del med venner (afslører reservationer)',
|
lockDeletion: 'Lås Sletning',
|
||||||
shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)',
|
unlockDeletion: 'Lås Op for Sletning',
|
||||||
copy: 'Kopiér',
|
shareViewOnly: 'Del med venner (afslører reservationer)',
|
||||||
copied: 'Kopieret!',
|
shareEditLink: 'Dit redigeringslink (giver redigeringsadgang)',
|
||||||
signInToSave: 'Log ind for at gemme',
|
copy: 'Kopiér',
|
||||||
saveThisWishlist: 'Gem Denne Ønskeliste',
|
copied: 'Kopieret!',
|
||||||
saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard',
|
signInToSave: 'Log ind for at gemme',
|
||||||
creating: 'Opretter...',
|
saveThisWishlist: 'Gem Denne Ønskeliste',
|
||||||
createWishlist: 'Opret Ønskeliste'
|
saveDescription: 'Gem denne ønskeliste for nemt at finde den senere i dit dashboard',
|
||||||
},
|
creating: 'Opretter...',
|
||||||
|
createWishlist: 'Opret Ønskeliste'
|
||||||
|
},
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
form: {
|
form: {
|
||||||
title: 'Titel',
|
title: 'Titel',
|
||||||
wishlistTitle: 'Ønskeliste Titel',
|
wishlistTitle: 'Ønskeliste Titel',
|
||||||
wishlistTitlePlaceholder: 'Min Fødselsdagsønskeliste',
|
wishlistTitlePlaceholder: 'Min Fødselsdagsønskeliste',
|
||||||
description: 'Beskrivelse',
|
description: 'Beskrivelse',
|
||||||
descriptionPlaceholder: 'Tilføj kontekst til din ønskeliset',
|
descriptionPlaceholder: 'Tilføj kontekst til din ønskeliset',
|
||||||
descriptionOptional: 'Beskrivelse (valgfri)',
|
descriptionOptional: 'Beskrivelse (valgfri)',
|
||||||
noDescription: 'Ingen beskrivelse',
|
noDescription: 'Ingen beskrivelse',
|
||||||
price: 'Pris',
|
price: 'Pris',
|
||||||
currency: 'Valuta',
|
currency: 'Valuta',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
link: 'Link (URL)',
|
link: 'Link (URL)',
|
||||||
image: 'Billede',
|
image: 'Billede',
|
||||||
imageUrl: 'Billede URL',
|
imageUrl: 'Billede URL',
|
||||||
submit: 'Indsend',
|
submit: 'Indsend',
|
||||||
cancel: 'Annuller',
|
cancel: 'Annuller',
|
||||||
save: 'Gem',
|
save: 'Gem',
|
||||||
saveChanges: 'Gem Ændringer',
|
saveChanges: 'Gem Ændringer',
|
||||||
delete: 'Slet',
|
delete: 'Slet',
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
password: 'Adgangskode',
|
password: 'Adgangskode',
|
||||||
confirmPassword: 'Bekræft Adgangskode',
|
confirmPassword: 'Bekræft Adgangskode',
|
||||||
name: 'Navn',
|
name: 'Navn',
|
||||||
username: 'Brugernavn',
|
username: 'Brugernavn',
|
||||||
wishName: 'Ønskenavn',
|
wishName: 'Ønskenavn',
|
||||||
yourName: 'Dit navn',
|
yourName: 'Dit navn',
|
||||||
optional: 'valgfri',
|
optional: 'valgfri',
|
||||||
required: 'påkrævet',
|
required: 'påkrævet',
|
||||||
color: 'Farve',
|
color: 'Farve',
|
||||||
wishlistColor: 'Ønskeliste Farve (valgfri)',
|
wishlistColor: 'Ønskeliste Farve (valgfri)',
|
||||||
cardColor: 'Kortfarve (valgfri)',
|
cardColor: 'Kortfarve (valgfri)',
|
||||||
endDate: 'Slutdato',
|
endDate: 'Slutdato',
|
||||||
endDateOptional: 'Slutdato (valgfri)',
|
endDateOptional: 'Slutdato (valgfri)',
|
||||||
position: 'Position i Listen',
|
position: 'Position i Listen',
|
||||||
addNewWish: 'Tilføj Nyt Ønske'
|
addNewWish: 'Tilføj Nyt Ønske'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
auth: {
|
auth: {
|
||||||
signIn: 'Log Ind',
|
signIn: 'Log Ind',
|
||||||
signUp: 'Tilmeld',
|
signUp: 'Tilmeld',
|
||||||
signOut: 'Log Ud',
|
signOut: 'Log Ud',
|
||||||
signingIn: 'Logger ind...',
|
signingIn: 'Logger ind...',
|
||||||
welcome: 'Velkommen',
|
welcome: 'Velkommen',
|
||||||
welcomeBack: 'Velkommen Tilbage',
|
welcomeBack: 'Velkommen Tilbage',
|
||||||
signInPrompt: 'Log ind på din konto',
|
signInPrompt: 'Log ind på din konto',
|
||||||
signUpPrompt: 'Tilmeld dig for at administrere dine ønskelister',
|
signUpPrompt: 'Tilmeld dig for at administrere dine ønskelister',
|
||||||
createAccount: 'Opret en Konto',
|
createAccount: 'Opret en Konto',
|
||||||
alreadyHaveAccount: 'Har du allerede en konto?',
|
alreadyHaveAccount: 'Har du allerede en konto?',
|
||||||
dontHaveAccount: 'Har du ikke en konto?',
|
dontHaveAccount: 'Har du ikke en konto?',
|
||||||
continueWith: 'Eller fortsæt med'
|
continueWith: 'Eller fortsæt med'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
loading: 'Indlæser...',
|
loading: 'Indlæser...',
|
||||||
error: 'Fejl',
|
error: 'Fejl',
|
||||||
success: 'Succes',
|
success: 'Succes',
|
||||||
confirm: 'Bekræft',
|
confirm: 'Bekræft',
|
||||||
close: 'Luk',
|
close: 'Luk',
|
||||||
or: 'eller',
|
or: 'eller',
|
||||||
and: 'og'
|
and: 'og'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reservation
|
// Reservation
|
||||||
reservation: {
|
reservation: {
|
||||||
reserveThis: 'Reservér Denne',
|
reserveThis: 'Reservér Denne',
|
||||||
cancelReservation: 'Annuller Reservation',
|
cancelReservation: 'Annuller Reservation',
|
||||||
yourNameOptional: 'Dit navn (valgfri)',
|
yourNameOptional: 'Dit navn (valgfri)',
|
||||||
confirm: 'Bekræft',
|
confirm: 'Bekræft',
|
||||||
cancel: 'Annuller'
|
cancel: 'Annuller'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Date formatting
|
// Date formatting
|
||||||
date: {
|
date: {
|
||||||
format: {
|
format: {
|
||||||
short: 'da-DK',
|
short: 'da-DK',
|
||||||
long: 'da-DK'
|
long: 'da-DK'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,162 +1,164 @@
|
|||||||
export const en = {
|
export const en = {
|
||||||
// Navigation
|
// Navigation
|
||||||
nav: {
|
nav: {
|
||||||
dashboard: 'Dashboard'
|
dashboard: 'Dashboard'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard
|
||||||
dashboard: {
|
dashboard: {
|
||||||
myWishlists: 'My Wishlists',
|
myWishlists: 'My Wishlists',
|
||||||
myWishlistsDescription: 'Wishlists you own and manage',
|
myWishlistsDescription: 'Wishlists you own and manage',
|
||||||
claimedWishlists: 'Claimed Wishlists',
|
claimedWishlists: 'Claimed Wishlists',
|
||||||
claimedWishlistsDescription: 'Wishlists you have claimed and can edit',
|
claimedWishlistsDescription: 'Wishlists you have claimed and can edit',
|
||||||
savedWishlists: 'Saved Wishlists',
|
savedWishlists: 'Saved Wishlists',
|
||||||
savedWishlistsDescription: "Wishlists you're following",
|
savedWishlistsDescription: "Wishlists you're following",
|
||||||
createNew: '+ Create New',
|
createNew: '+ Create New',
|
||||||
manage: 'Manage',
|
manage: 'Manage',
|
||||||
copyLink: 'Copy Link',
|
copyLink: 'Copy Link',
|
||||||
viewWishlist: 'View Wishlist',
|
viewWishlist: 'View Wishlist',
|
||||||
unsave: 'Unsave',
|
unsave: 'Unsave',
|
||||||
unclaim: 'Unclaim',
|
unclaim: 'Unclaim',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
emptyWishlists: "You haven't created any wishlists yet.",
|
emptyWishlists: "You haven't created any wishlists yet.",
|
||||||
emptyWishlistsAction: 'Create Your First Wishlist',
|
emptyWishlistsAction: 'Create Your First Wishlist',
|
||||||
emptyClaimedWishlists: "You haven't claimed any wishlists yet.",
|
emptyClaimedWishlists: "You haven't claimed any wishlists yet.",
|
||||||
emptyClaimedWishlistsDescription: "When someone shares an edit link with you, you can claim it to manage it from your dashboard.",
|
emptyClaimedWishlistsDescription:
|
||||||
emptySavedWishlists: "You haven't saved any wishlists yet.",
|
'When someone shares an edit link with you, you can claim it to manage it from your dashboard.',
|
||||||
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.",
|
emptySavedWishlists: "You haven't saved any wishlists yet.",
|
||||||
by: 'by',
|
emptySavedWishlistsDescription:
|
||||||
ends: 'Ends',
|
"When viewing someone's wishlist, you can save it to easily find it later.",
|
||||||
welcomeBack: 'Welcome back',
|
by: 'by',
|
||||||
searchPlaceholder: 'Search wishes...'
|
ends: 'Ends',
|
||||||
},
|
welcomeBack: 'Welcome back',
|
||||||
|
searchPlaceholder: 'Search wishes...'
|
||||||
|
},
|
||||||
|
|
||||||
// Wishlist
|
// Wishlist
|
||||||
wishlist: {
|
wishlist: {
|
||||||
title: 'Wishlist',
|
title: 'Wishlist',
|
||||||
createTitle: 'Create Your Wishlist',
|
createTitle: 'Create Your Wishlist',
|
||||||
createDescription: 'Create a wishlist and share it with friends and family',
|
createDescription: 'Create a wishlist and share it with friends and family',
|
||||||
addWish: '+ Add Wish',
|
addWish: '+ Add Wish',
|
||||||
editWish: 'Edit Wish',
|
editWish: 'Edit Wish',
|
||||||
deleteWish: 'Delete Wish',
|
deleteWish: 'Delete Wish',
|
||||||
reserve: 'Reserve',
|
reserve: 'Reserve',
|
||||||
unreserve: 'Unreserve',
|
unreserve: 'Unreserve',
|
||||||
reserved: 'Reserved',
|
reserved: 'Reserved',
|
||||||
reservedBy: 'by',
|
reservedBy: 'by',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saveWishlist: 'Save Wishlist',
|
saveWishlist: 'Save Wishlist',
|
||||||
unsaveWishlist: 'Unsave',
|
unsaveWishlist: 'Unsave',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
noWishes: 'No wishes yet',
|
noWishes: 'No wishes yet',
|
||||||
addFirstWish: 'Add your first wish',
|
addFirstWish: 'Add your first wish',
|
||||||
emptyWishes: "This wishlist doesn't have any wishes yet.",
|
emptyWishes: "This wishlist doesn't have any wishes yet.",
|
||||||
viewProduct: 'View Product',
|
viewProduct: 'View Product',
|
||||||
claimWishlist: 'Claim Wishlist',
|
claimWishlist: 'Claim Wishlist',
|
||||||
unclaimWishlist: 'Unclaim Wishlist',
|
unclaimWishlist: 'Unclaim Wishlist',
|
||||||
youOwnThis: 'You Own This Wishlist',
|
youOwnThis: 'You Own This Wishlist',
|
||||||
youClaimedThis: 'You Have Claimed This Wishlist',
|
youClaimedThis: 'You Have Claimed This Wishlist',
|
||||||
alreadyInDashboard: 'This wishlist is already in your dashboard as the owner.',
|
alreadyInDashboard: 'This wishlist is already in your dashboard as the owner.',
|
||||||
alreadyClaimed: 'This wishlist is already in your dashboard as a claimed wishlist.',
|
alreadyClaimed: 'This wishlist is already in your dashboard as a claimed wishlist.',
|
||||||
claimDescription: 'Claim this wishlist to add it to your dashboard',
|
claimDescription: 'Claim this wishlist to add it to your dashboard',
|
||||||
claimedDescription: 'You have claimed this wishlist and can access it from your dashboard',
|
claimedDescription: 'You have claimed this wishlist and can access it from your dashboard',
|
||||||
deleteWishlist: 'Delete Wishlist',
|
deleteWishlist: 'Delete Wishlist',
|
||||||
deleteConfirm: 'Are you sure you want to delete this wishlist? This action cannot be undone.',
|
deleteConfirm: 'Are you sure you want to delete this wishlist? This action cannot be undone.',
|
||||||
lockDeletion: 'Lock Deletion',
|
lockDeletion: 'Lock Deletion',
|
||||||
unlockDeletion: 'Unlock for Deletion',
|
unlockDeletion: 'Unlock for Deletion',
|
||||||
shareViewOnly: 'Share with friends (view only)',
|
shareViewOnly: 'Share with friends (view only)',
|
||||||
shareEditLink: 'Your edit link (keep this private!)',
|
shareEditLink: 'Your edit link (keep this private!)',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
copied: 'Copied!',
|
copied: 'Copied!',
|
||||||
signInToSave: 'Sign in to Save',
|
signInToSave: 'Sign in to Save',
|
||||||
saveThisWishlist: 'Save This Wishlist',
|
saveThisWishlist: 'Save This Wishlist',
|
||||||
saveDescription: 'Save this wishlist to easily find it later in your dashboard',
|
saveDescription: 'Save this wishlist to easily find it later in your dashboard',
|
||||||
creating: 'Creating...',
|
creating: 'Creating...',
|
||||||
createWishlist: 'Create Wishlist'
|
createWishlist: 'Create Wishlist'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
form: {
|
form: {
|
||||||
title: 'Title',
|
title: 'Title',
|
||||||
wishlistTitle: 'Wishlist Title',
|
wishlistTitle: 'Wishlist Title',
|
||||||
wishlistTitlePlaceholder: 'My Birthday Wishlist',
|
wishlistTitlePlaceholder: 'My Birthday Wishlist',
|
||||||
description: 'Description',
|
description: 'Description',
|
||||||
descriptionPlaceholder: 'Add some context for your wishlist...',
|
descriptionPlaceholder: 'Add some context for your wishlist...',
|
||||||
descriptionOptional: 'Description (optional)',
|
descriptionOptional: 'Description (optional)',
|
||||||
noDescription: 'No description',
|
noDescription: 'No description',
|
||||||
price: 'Price',
|
price: 'Price',
|
||||||
currency: 'Currency',
|
currency: 'Currency',
|
||||||
url: 'URL',
|
url: 'URL',
|
||||||
link: 'Link (URL)',
|
link: 'Link (URL)',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
imageUrl: 'Image URL',
|
imageUrl: 'Image URL',
|
||||||
submit: 'Submit',
|
submit: 'Submit',
|
||||||
cancel: 'Cancel',
|
cancel: 'Cancel',
|
||||||
save: 'Save',
|
save: 'Save',
|
||||||
saveChanges: 'Save Changes',
|
saveChanges: 'Save Changes',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
confirmPassword: 'Confirm Password',
|
confirmPassword: 'Confirm Password',
|
||||||
name: 'Name',
|
name: 'Name',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
wishName: 'Wish Name',
|
wishName: 'Wish Name',
|
||||||
yourName: 'Your name',
|
yourName: 'Your name',
|
||||||
optional: 'optional',
|
optional: 'optional',
|
||||||
required: 'required',
|
required: 'required',
|
||||||
color: 'Color',
|
color: 'Color',
|
||||||
wishlistColor: 'Wishlist Color (optional)',
|
wishlistColor: 'Wishlist Color (optional)',
|
||||||
cardColor: 'Card Color (optional)',
|
cardColor: 'Card Color (optional)',
|
||||||
endDate: 'End Date',
|
endDate: 'End Date',
|
||||||
endDateOptional: 'End Date (optional)',
|
endDateOptional: 'End Date (optional)',
|
||||||
position: 'Position in List',
|
position: 'Position in List',
|
||||||
addNewWish: 'Add New Wish'
|
addNewWish: 'Add New Wish'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
auth: {
|
auth: {
|
||||||
signIn: 'Sign In',
|
signIn: 'Sign In',
|
||||||
signUp: 'Sign Up',
|
signUp: 'Sign Up',
|
||||||
signOut: 'Sign Out',
|
signOut: 'Sign Out',
|
||||||
signingIn: 'Signing in...',
|
signingIn: 'Signing in...',
|
||||||
welcome: 'Welcome',
|
welcome: 'Welcome',
|
||||||
welcomeBack: 'Welcome Back',
|
welcomeBack: 'Welcome Back',
|
||||||
signInPrompt: 'Sign in to your account',
|
signInPrompt: 'Sign in to your account',
|
||||||
signUpPrompt: 'Sign up to manage your wishlists',
|
signUpPrompt: 'Sign up to manage your wishlists',
|
||||||
createAccount: 'Create an Account',
|
createAccount: 'Create an Account',
|
||||||
alreadyHaveAccount: 'Already have an account?',
|
alreadyHaveAccount: 'Already have an account?',
|
||||||
dontHaveAccount: "Don't have an account?",
|
dontHaveAccount: "Don't have an account?",
|
||||||
continueWith: 'Or continue with'
|
continueWith: 'Or continue with'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
common: {
|
common: {
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
error: 'Error',
|
error: 'Error',
|
||||||
success: 'Success',
|
success: 'Success',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
and: 'and'
|
and: 'and'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reservation
|
// Reservation
|
||||||
reservation: {
|
reservation: {
|
||||||
reserveThis: 'Reserve This',
|
reserveThis: 'Reserve This',
|
||||||
cancelReservation: 'Cancel Reservation',
|
cancelReservation: 'Cancel Reservation',
|
||||||
yourNameOptional: 'Your name (optional)',
|
yourNameOptional: 'Your name (optional)',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
cancel: 'Cancel'
|
cancel: 'Cancel'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Date formatting
|
// Date formatting
|
||||||
date: {
|
date: {
|
||||||
format: {
|
format: {
|
||||||
short: 'en-US',
|
short: 'en-US',
|
||||||
long: 'en-US'
|
long: 'en-US'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Translation = typeof en;
|
export type Translation = typeof en;
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { da } from './da';
|
|||||||
import type { Translation } from './en';
|
import type { Translation } from './en';
|
||||||
|
|
||||||
export const translations: Record<string, Translation> = {
|
export const translations: Record<string, Translation> = {
|
||||||
en,
|
en,
|
||||||
da
|
da
|
||||||
};
|
};
|
||||||
|
|
||||||
export const languages = [
|
export const languages = [
|
||||||
{ code: 'en', name: 'English' },
|
{ code: 'en', name: 'English' },
|
||||||
{ code: 'da', name: 'Dansk' }
|
{ code: 'da', name: 'Dansk' }
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LanguageCode = 'en' | 'da';
|
export type LanguageCode = 'en' | 'da';
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
@@ -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 './schema';
|
import * as schema from '$lib/db/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 });
|
||||||
|
|||||||
@@ -1,179 +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 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;
|
|
||||||
@@ -1,86 +1,132 @@
|
|||||||
export function sanitizeString(input: string | null | undefined, maxLength: number = 1000): string | null {
|
export function sanitizeString(
|
||||||
if (input === null || input === undefined) {
|
input: string | null | undefined,
|
||||||
return null;
|
maxLength: number = 1000
|
||||||
}
|
): string | null {
|
||||||
const trimmed = input.trim();
|
if (input === null || input === undefined) {
|
||||||
if (trimmed.length > maxLength) {
|
return null;
|
||||||
throw new Error(`Input exceeds maximum length of ${maxLength}`);
|
}
|
||||||
}
|
const trimmed = input.trim();
|
||||||
return trimmed;
|
if (trimmed.length > maxLength) {
|
||||||
|
throw new Error(`Input exceeds maximum length of ${maxLength}`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeUrl(url: string | null | undefined): string | null {
|
export function sanitizeUrl(url: string | null | undefined): string | null {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return url.trim();
|
return url.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeText(input: string | null | undefined, maxLength: number = 10000): string | null {
|
export function sanitizeText(
|
||||||
if (input === null || input === undefined) {
|
input: string | null | undefined,
|
||||||
return null;
|
maxLength: number = 10000
|
||||||
}
|
): string | null {
|
||||||
const trimmed = input.trim();
|
if (input === null || input === undefined) {
|
||||||
if (trimmed.length > maxLength) {
|
return null;
|
||||||
throw new Error(`Text exceeds maximum length of ${maxLength}`);
|
}
|
||||||
}
|
const trimmed = input.trim();
|
||||||
return trimmed;
|
if (trimmed.length > maxLength) {
|
||||||
|
throw new Error(`Text exceeds maximum length of ${maxLength}`);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizePrice(price: string | null | undefined): number | null {
|
export function sanitizePrice(price: string | null | undefined): number | null {
|
||||||
if (!price) {
|
if (!price) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = parseFloat(price.trim());
|
const parsed = parseFloat(price.trim());
|
||||||
if (isNaN(parsed)) {
|
if (isNaN(parsed)) {
|
||||||
throw new Error('Invalid price format');
|
throw new Error('Invalid price format');
|
||||||
}
|
}
|
||||||
if (parsed < 0) {
|
if (parsed < 0) {
|
||||||
throw new Error('Price cannot be negative');
|
throw new Error('Price cannot be negative');
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeColor(color: string | null | undefined): string | null {
|
export function sanitizeColor(color: string | null | undefined): string | null {
|
||||||
if (!color) {
|
if (!color) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return color.trim();
|
return color.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeCurrency(currency: string | null | undefined): string {
|
export function sanitizeCurrency(currency: string | null | undefined): string {
|
||||||
if (!currency) {
|
if (!currency) {
|
||||||
return 'DKK';
|
return 'DKK';
|
||||||
}
|
}
|
||||||
return currency.trim().toUpperCase();
|
return currency.trim().toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeUsername(username: string): string {
|
export function sanitizeUsername(username: string): string {
|
||||||
const trimmed = username.trim().toLowerCase();
|
const trimmed = username.trim().toLowerCase();
|
||||||
if (trimmed.length < 3 || trimmed.length > 50) {
|
if (trimmed.length < 3 || trimmed.length > 50) {
|
||||||
throw new Error('Username must be between 3 and 50 characters');
|
throw new Error('Username must be between 3 and 50 characters');
|
||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeId(id: string | null | undefined): string {
|
export function sanitizeId(id: string | null | undefined): string {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
throw new Error('ID is required');
|
throw new Error('ID is required');
|
||||||
}
|
}
|
||||||
const trimmed = id.trim();
|
const trimmed = id.trim();
|
||||||
if (trimmed.length === 0 || trimmed.length > 100) {
|
if (trimmed.length === 0 || trimmed.length > 100) {
|
||||||
throw new Error('Invalid ID');
|
throw new Error('Invalid ID');
|
||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeToken(token: string | null | undefined): string {
|
export function sanitizeToken(token: string | null | undefined): string {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
throw new Error('Token is required');
|
throw new Error('Token is required');
|
||||||
}
|
}
|
||||||
const trimmed = token.trim();
|
const trimmed = token.trim();
|
||||||
if (trimmed.length === 0 || trimmed.length > 100) {
|
if (trimmed.length === 0 || trimmed.length > 100) {
|
||||||
throw new Error('Invalid token');
|
throw new Error('Invalid token');
|
||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zod schemas for type-safe form validation
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { CURRENCIES } from '$lib/utils/currency';
|
||||||
|
|
||||||
|
export const currencySchema = z.enum(CURRENCIES);
|
||||||
|
|
||||||
|
export const itemSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(255),
|
||||||
|
description: z.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>;
|
||||||
|
|||||||
@@ -4,60 +4,60 @@ import type { Translation } from '$lib/i18n/translations/en';
|
|||||||
const LANGUAGE_KEY = 'preferred-language';
|
const LANGUAGE_KEY = 'preferred-language';
|
||||||
|
|
||||||
function getStoredLanguage(): LanguageCode {
|
function getStoredLanguage(): LanguageCode {
|
||||||
if (typeof window === 'undefined') return 'en';
|
if (typeof window === 'undefined') return 'en';
|
||||||
|
|
||||||
const stored = localStorage.getItem(LANGUAGE_KEY);
|
const stored = localStorage.getItem(LANGUAGE_KEY);
|
||||||
if (stored && (stored === 'en' || stored === 'da')) {
|
if (stored && (stored === 'en' || stored === 'da')) {
|
||||||
return stored as LanguageCode;
|
return stored as LanguageCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to detect from browser
|
// Try to detect from browser
|
||||||
const browserLang = navigator.language.toLowerCase();
|
const browserLang = navigator.language.toLowerCase();
|
||||||
if (browserLang.startsWith('da')) {
|
if (browserLang.startsWith('da')) {
|
||||||
return 'da';
|
return 'da';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'en';
|
return 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
class LanguageStore {
|
class LanguageStore {
|
||||||
private _current = $state<LanguageCode>(getStoredLanguage());
|
private _current = $state<LanguageCode>(getStoredLanguage());
|
||||||
|
|
||||||
get current(): LanguageCode {
|
get current(): LanguageCode {
|
||||||
return this._current;
|
return this._current;
|
||||||
}
|
}
|
||||||
|
|
||||||
set current(value: LanguageCode) {
|
set current(value: LanguageCode) {
|
||||||
this._current = value;
|
this._current = value;
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem(LANGUAGE_KEY, value);
|
localStorage.setItem(LANGUAGE_KEY, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get t(): Translation {
|
get t(): Translation {
|
||||||
return translations[this._current];
|
return translations[this._current];
|
||||||
}
|
}
|
||||||
|
|
||||||
setLanguage(lang: LanguageCode) {
|
setLanguage(lang: LanguageCode) {
|
||||||
this.current = lang;
|
this.current = lang;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const languageStore = new LanguageStore();
|
export const languageStore = new LanguageStore();
|
||||||
|
|
||||||
// Helper function to get nested translation value
|
// Helper function to get nested translation value
|
||||||
export function t(path: string): string {
|
export function t(path: string): string {
|
||||||
const keys = path.split('.');
|
const keys = path.split('.');
|
||||||
let value: any = languageStore.t;
|
let value: unknown = languageStore.t;
|
||||||
|
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (value && typeof value === 'object' && key in value) {
|
if (value && typeof value === 'object' && key in value) {
|
||||||
value = value[key];
|
value = value[key];
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Translation key not found: ${path}`);
|
console.warn(`Translation key not found: ${path}`);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return typeof value === 'string' ? value : path;
|
return typeof value === 'string' ? value : path;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,66 +4,67 @@ type Theme = 'light' | 'dark' | 'system';
|
|||||||
type ResolvedTheme = 'light' | 'dark';
|
type ResolvedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
class ThemeStore {
|
class ThemeStore {
|
||||||
current = $state<Theme>('system');
|
current = $state<Theme>('system');
|
||||||
resolved = $state<ResolvedTheme>('light');
|
resolved = $state<ResolvedTheme>('light');
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const stored = localStorage.getItem('theme') as Theme | null;
|
const stored = localStorage.getItem('theme') as Theme | null;
|
||||||
this.current = stored || 'system';
|
this.current = stored || 'system';
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
mediaQuery.addEventListener('change', () => {
|
mediaQuery.addEventListener('change', () => {
|
||||||
if (this.current === 'system') {
|
if (this.current === 'system') {
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyTheme() {
|
private applyTheme() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
const isDark = this.current === 'dark' ||
|
const isDark =
|
||||||
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
this.current === 'dark' ||
|
||||||
|
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
|
||||||
this.resolved = isDark ? 'dark' : 'light';
|
this.resolved = isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.classList.remove('dark');
|
document.documentElement.classList.remove('dark');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getResolvedTheme(): ResolvedTheme {
|
getResolvedTheme(): ResolvedTheme {
|
||||||
return this.resolved;
|
return this.resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
// Cycle through: light -> dark -> system -> light
|
// Cycle through: light -> dark -> system -> light
|
||||||
if (this.current === 'light') {
|
if (this.current === 'light') {
|
||||||
this.current = 'dark';
|
this.current = 'dark';
|
||||||
} else if (this.current === 'dark') {
|
} else if (this.current === 'dark') {
|
||||||
this.current = 'system';
|
this.current = 'system';
|
||||||
} else {
|
} else {
|
||||||
this.current = 'light';
|
this.current = 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('theme', this.current);
|
localStorage.setItem('theme', this.current);
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set(theme: Theme) {
|
set(theme: Theme) {
|
||||||
this.current = theme;
|
this.current = theme;
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('theme', this.current);
|
localStorage.setItem('theme', this.current);
|
||||||
}
|
}
|
||||||
this.applyTheme();
|
this.applyTheme();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const themeStore = new ThemeStore();
|
export const themeStore = new ThemeStore();
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
* Convert hex color to rgba with transparency
|
* Convert hex color to rgba with transparency
|
||||||
*/
|
*/
|
||||||
export function hexToRgba(hex: string, alpha: number): string {
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
const r = parseInt(hex.slice(1, 3), 16);
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
const g = parseInt(hex.slice(3, 5), 16);
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
const b = parseInt(hex.slice(5, 7), 16);
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate card style string with color, transparency, and blur
|
* Generate card style string with color, transparency, and blur
|
||||||
*/
|
*/
|
||||||
export function getCardStyle(color: string | null, fallbackColor?: string | null): string {
|
export function getCardStyle(color: string | null, fallbackColor?: string | null): string {
|
||||||
const activeColor = color || fallbackColor;
|
const activeColor = color || fallbackColor;
|
||||||
if (!activeColor) return '';
|
if (!activeColor) return '';
|
||||||
|
|
||||||
const opacity = color ? 0.2 : 0.15;
|
const opacity = color ? 0.2 : 0.15;
|
||||||
return `background-color: ${hexToRgba(activeColor, opacity)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
|
return `background-color: ${hexToRgba(activeColor, opacity)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
|
||||||
}
|
}
|
||||||
|
|||||||
41
src/lib/utils/currency.ts
Normal file
41
src/lib/utils/currency.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Currency utilities for formatting and displaying prices
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CURRENCIES = ['DKK', 'EUR', 'USD', 'SEK', 'NOK', 'GBP'] as const;
|
||||||
|
|
||||||
|
export type Currency = (typeof CURRENCIES)[number];
|
||||||
|
|
||||||
|
export const currencySymbols: Record<Currency, string> = {
|
||||||
|
DKK: 'kr',
|
||||||
|
EUR: '€',
|
||||||
|
USD: '$',
|
||||||
|
SEK: 'kr',
|
||||||
|
NOK: 'kr',
|
||||||
|
GBP: '£'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a currency symbol should be placed after the amount
|
||||||
|
*/
|
||||||
|
export function isPostfixCurrency(currency: Currency): boolean {
|
||||||
|
return ['DKK', 'SEK', 'NOK'].includes(currency);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price with the appropriate currency symbol
|
||||||
|
*/
|
||||||
|
export function formatPrice(price: string | number | null, currency: Currency | null): string {
|
||||||
|
if (!price) return '';
|
||||||
|
|
||||||
|
const symbol = currency ? currencySymbols[currency] || currency : 'kr';
|
||||||
|
const amount = typeof price === 'string' ? parseFloat(price).toFixed(2) : price.toFixed(2);
|
||||||
|
|
||||||
|
// For Danish, Swedish, Norwegian kroner, put symbol after the amount
|
||||||
|
if (currency && isPostfixCurrency(currency)) {
|
||||||
|
return `${amount} ${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other currencies, put symbol before
|
||||||
|
return `${symbol}${amount}`;
|
||||||
|
}
|
||||||
@@ -5,98 +5,96 @@
|
|||||||
const LOCAL_WISHLISTS_KEY = 'local_wishlists';
|
const LOCAL_WISHLISTS_KEY = 'local_wishlists';
|
||||||
|
|
||||||
export interface LocalWishlist {
|
export interface LocalWishlist {
|
||||||
ownerToken: string;
|
ownerToken: string;
|
||||||
publicToken: string;
|
publicToken: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all local wishlists from localStorage
|
* Get all local wishlists from localStorage
|
||||||
*/
|
*/
|
||||||
export function getLocalWishlists(): LocalWishlist[] {
|
export function getLocalWishlists(): LocalWishlist[] {
|
||||||
if (typeof window === 'undefined') return [];
|
if (typeof window === 'undefined') return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY);
|
const stored = localStorage.getItem(LOCAL_WISHLISTS_KEY);
|
||||||
return stored ? JSON.parse(stored) : [];
|
return stored ? JSON.parse(stored) : [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to parse local wishlists:', error);
|
console.error('Failed to parse local wishlists:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a wishlist to localStorage
|
* Add a wishlist to localStorage
|
||||||
*/
|
*/
|
||||||
export function addLocalWishlist(wishlist: LocalWishlist): void {
|
export function addLocalWishlist(wishlist: LocalWishlist): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wishlists = getLocalWishlists();
|
const wishlists = getLocalWishlists();
|
||||||
|
|
||||||
const exists = wishlists.some(w => w.ownerToken === wishlist.ownerToken);
|
const exists = wishlists.some((w) => w.ownerToken === wishlist.ownerToken);
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
|
|
||||||
wishlists.push(wishlist);
|
wishlists.push(wishlist);
|
||||||
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists));
|
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(wishlists));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add local wishlist:', error);
|
console.error('Failed to add local wishlist:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a wishlist from localStorage (forget it)
|
* Remove a wishlist from localStorage (forget it)
|
||||||
*/
|
*/
|
||||||
export function forgetLocalWishlist(ownerToken: string): void {
|
export function forgetLocalWishlist(ownerToken: string): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wishlists = getLocalWishlists();
|
const wishlists = getLocalWishlists();
|
||||||
const filtered = wishlists.filter(w => w.ownerToken !== ownerToken);
|
const filtered = wishlists.filter((w) => w.ownerToken !== ownerToken);
|
||||||
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered));
|
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(filtered));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to forget local wishlist:', error);
|
console.error('Failed to forget local wishlist:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all local wishlists (e.g., when user claims all wishlists)
|
* Clear all local wishlists (e.g., when user claims all wishlists)
|
||||||
*/
|
*/
|
||||||
export function clearLocalWishlists(): void {
|
export function clearLocalWishlists(): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(LOCAL_WISHLISTS_KEY);
|
localStorage.removeItem(LOCAL_WISHLISTS_KEY);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to clear local wishlists:', error);
|
console.error('Failed to clear local wishlists:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a wishlist is in local storage
|
* Check if a wishlist is in local storage
|
||||||
*/
|
*/
|
||||||
export function isLocalWishlist(ownerToken: string): boolean {
|
export function isLocalWishlist(ownerToken: string): boolean {
|
||||||
const wishlists = getLocalWishlists();
|
const wishlists = getLocalWishlists();
|
||||||
return wishlists.some(w => w.ownerToken === ownerToken);
|
return wishlists.some((w) => w.ownerToken === ownerToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle favorite status for a local wishlist
|
* Toggle favorite status for a local wishlist
|
||||||
*/
|
*/
|
||||||
export function toggleLocalFavorite(ownerToken: string): void {
|
export function toggleLocalFavorite(ownerToken: string): void {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wishlists = getLocalWishlists();
|
const wishlists = getLocalWishlists();
|
||||||
const updated = wishlists.map(w =>
|
const updated = wishlists.map((w) =>
|
||||||
w.ownerToken === ownerToken
|
w.ownerToken === ownerToken ? { ...w, isFavorite: !w.isFavorite } : w
|
||||||
? { ...w, isFavorite: !w.isFavorite }
|
);
|
||||||
: w
|
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
|
||||||
);
|
} catch (error) {
|
||||||
localStorage.setItem(LOCAL_WISHLISTS_KEY, JSON.stringify(updated));
|
console.error('Failed to toggle local wishlist favorite:', error);
|
||||||
} catch (error) {
|
}
|
||||||
console.error('Failed to toggle local wishlist favorite:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,31 +3,31 @@ import { themeStore } from '$lib/stores/theme.svelte';
|
|||||||
export type ThemePattern = 'snow' | 'none';
|
export type ThemePattern = 'snow' | 'none';
|
||||||
|
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
name: string;
|
name: string;
|
||||||
pattern: ThemePattern;
|
pattern: ThemePattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AVAILABLE_THEMES: Record<string, Theme> = {
|
export const AVAILABLE_THEMES: Record<string, Theme> = {
|
||||||
none: {
|
none: {
|
||||||
name: 'None',
|
name: 'None',
|
||||||
pattern: 'none'
|
pattern: 'none'
|
||||||
},
|
},
|
||||||
waves: {
|
waves: {
|
||||||
name: 'Snow',
|
name: 'Snow',
|
||||||
pattern: 'snow'
|
pattern: 'snow'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_THEME = 'none';
|
export const DEFAULT_THEME = 'none';
|
||||||
export const PATTERN_OPACITY = 0.1;
|
export const PATTERN_OPACITY = 0.1;
|
||||||
|
|
||||||
export function getTheme(themeName?: string | null): Theme {
|
export function getTheme(themeName?: string | null): Theme {
|
||||||
if (!themeName || !AVAILABLE_THEMES[themeName]) {
|
if (!themeName || !AVAILABLE_THEMES[themeName]) {
|
||||||
return AVAILABLE_THEMES[DEFAULT_THEME];
|
return AVAILABLE_THEMES[DEFAULT_THEME];
|
||||||
}
|
}
|
||||||
return AVAILABLE_THEMES[themeName];
|
return AVAILABLE_THEMES[themeName];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPatternColor(customColor?: string): string {
|
export function getPatternColor(customColor?: string): string {
|
||||||
return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000');
|
return customColor || (themeStore.getResolvedTheme() === 'dark' ? '#FFFFFF' : '#000000');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,59 +3,59 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
type UpdateField = {
|
type UpdateField = {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateWishlist(field: UpdateField): Promise<boolean> {
|
async function updateWishlist(field: UpdateField): Promise<boolean> {
|
||||||
const response = await fetch("?/updateWishlist", {
|
const response = await fetch('?/updateWishlist', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
},
|
||||||
body: new URLSearchParams(field),
|
body: new URLSearchParams(field)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`Failed to update wishlist: ${Object.keys(field)[0]}`);
|
console.error(`Failed to update wishlist: ${Object.keys(field)[0]}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTitle(title: string): Promise<boolean> {
|
export async function updateTitle(title: string): Promise<boolean> {
|
||||||
return updateWishlist({ title });
|
return updateWishlist({ title });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateDescription(description: string | null): Promise<boolean> {
|
export async function updateDescription(description: string | null): Promise<boolean> {
|
||||||
return updateWishlist({ description: description || "" });
|
return updateWishlist({ description: description || '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateColor(color: string | null): Promise<boolean> {
|
export async function updateColor(color: string | null): Promise<boolean> {
|
||||||
return updateWishlist({ color: color || "" });
|
return updateWishlist({ color: color || '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateEndDate(endDate: string | null): Promise<boolean> {
|
export async function updateEndDate(endDate: string | null): Promise<boolean> {
|
||||||
return updateWishlist({ endDate: endDate || "" });
|
return updateWishlist({ endDate: endDate || '' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateTheme(theme: string | null): Promise<boolean> {
|
export async function updateTheme(theme: string | null): Promise<boolean> {
|
||||||
return updateWishlist({ theme: theme || "none" });
|
return updateWishlist({ theme: theme || 'none' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
|
export async function reorderItems(items: Array<{ id: string; order: number }>): Promise<boolean> {
|
||||||
const response = await fetch("?/reorderItems", {
|
const response = await fetch('?/reorderItems', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
items: JSON.stringify(items),
|
items: JSON.stringify(items)
|
||||||
}),
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error("Failed to update item order");
|
console.error('Failed to update item order');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-50 dark:bg-slate-950">
|
<div class="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,49 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { wishlists } from '$lib/db/schema';
|
||||||
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { wishlistSchema } from '$lib/server/validation';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
const session = await event.locals.auth();
|
const session = await event.locals.auth();
|
||||||
return {
|
return {
|
||||||
session
|
session
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createWishlist: async ({ request, locals }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const rawData = Object.fromEntries(formData);
|
||||||
|
|
||||||
|
const result = wishlistSchema.safeParse(rawData);
|
||||||
|
if (!result.success) {
|
||||||
|
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
const userId = session?.user?.id || null;
|
||||||
|
|
||||||
|
const ownerToken = createId();
|
||||||
|
const publicToken = createId();
|
||||||
|
|
||||||
|
const [wishlist] = await db
|
||||||
|
.insert(wishlists)
|
||||||
|
.values({
|
||||||
|
...result.data,
|
||||||
|
ownerToken,
|
||||||
|
publicToken,
|
||||||
|
userId
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
ownerToken,
|
||||||
|
publicToken,
|
||||||
|
title: wishlist.title,
|
||||||
|
createdAt: wishlist.createdAt
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,111 +1,112 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
import { Textarea } from '$lib/components/ui/textarea';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import {
|
||||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
Card,
|
||||||
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
CardContent,
|
||||||
import { goto } from '$app/navigation';
|
CardDescription,
|
||||||
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
CardHeader,
|
||||||
import type { PageData } from './$types';
|
CardTitle
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
} from '$lib/components/ui/card';
|
||||||
import { addLocalWishlist } from '$lib/utils/localWishlists';
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||||
|
import type { PageData, ActionData } from './$types';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
import { addLocalWishlist } from '$lib/utils/localWishlists';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
let color = $state<string | null>(null);
|
let color = $state<string | null>(null);
|
||||||
let isCreating = $state(false);
|
let isCreating = $state(false);
|
||||||
|
|
||||||
async function createWishlist() {
|
|
||||||
if (!title.trim()) return;
|
|
||||||
|
|
||||||
isCreating = true;
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/wishlists', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ title, description, color })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const result = await response.json();
|
|
||||||
const { ownerToken, publicToken, title: wishlistTitle, createdAt } = result;
|
|
||||||
|
|
||||||
// If user is not authenticated, save to localStorage
|
|
||||||
if (!data.session?.user) {
|
|
||||||
addLocalWishlist({
|
|
||||||
ownerToken,
|
|
||||||
publicToken,
|
|
||||||
title: wishlistTitle,
|
|
||||||
createdAt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
goto(`/wishlist/${ownerToken}/edit`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create wishlist:', error);
|
|
||||||
} finally {
|
|
||||||
isCreating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-4">
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
<Card class="w-full max-w-lg">
|
<Card class="w-full max-w-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<CardTitle class="text-3xl">{t.wishlist.createTitle}</CardTitle>
|
<CardTitle class="text-3xl">{t.wishlist.createTitle}</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t.wishlist.createDescription}
|
{t.wishlist.createDescription}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
<div class="flex items-center gap-1 sm:gap-2 flex-shrink-0">
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button>
|
<Button variant="outline" onclick={() => goto('/dashboard')}>{t.nav.dashboard}</Button>
|
||||||
{#if !data.session?.user}
|
{#if !data.session?.user}
|
||||||
<Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button>
|
<Button variant="outline" onclick={() => goto('/signin')}>{t.auth.signIn}</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4">
|
<form
|
||||||
<div class="space-y-2">
|
method="POST"
|
||||||
<Label for="title">{t.form.wishlistTitle}</Label>
|
action="?/createWishlist"
|
||||||
<Input
|
use:enhance={() => {
|
||||||
id="title"
|
isCreating = true;
|
||||||
bind:value={title}
|
return async ({ result, update }) => {
|
||||||
placeholder={t.form.wishlistTitlePlaceholder}
|
if (result.type === 'success' && result.data) {
|
||||||
required
|
const data = result.data as { ownerToken: string; publicToken: string; title: string; createdAt: string };
|
||||||
/>
|
// If user is not authenticated, save to localStorage
|
||||||
</div>
|
if (!data?.session?.user) {
|
||||||
<div class="space-y-2">
|
addLocalWishlist({
|
||||||
<Label for="description">{t.form.descriptionOptional}</Label>
|
ownerToken: data.ownerToken,
|
||||||
<Textarea
|
publicToken: data.publicToken,
|
||||||
id="description"
|
title: data.title,
|
||||||
bind:value={description}
|
createdAt: new Date(data.createdAt)
|
||||||
placeholder={t.form.descriptionPlaceholder}
|
});
|
||||||
rows={3}
|
}
|
||||||
/>
|
goto(`/wishlist/${data.ownerToken}/edit`);
|
||||||
</div>
|
} else {
|
||||||
<div class="space-y-2">
|
await update();
|
||||||
<div class="flex items-center justify-between">
|
isCreating = false;
|
||||||
<Label for="color">{t.form.wishlistColor}</Label>
|
}
|
||||||
<ColorPicker bind:color={color} size="sm" />
|
};
|
||||||
</div>
|
}}
|
||||||
</div>
|
class="space-y-4"
|
||||||
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
|
>
|
||||||
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
|
<div class="space-y-2">
|
||||||
</Button>
|
<Label for="title">{t.form.wishlistTitle}</Label>
|
||||||
</form>
|
<Input
|
||||||
</CardContent>
|
id="title"
|
||||||
</Card>
|
name="title"
|
||||||
|
bind:value={title}
|
||||||
|
placeholder={t.form.wishlistTitlePlaceholder}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="description">{t.form.descriptionOptional}</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
bind:value={description}
|
||||||
|
placeholder={t.form.descriptionPlaceholder}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="color">{t.form.wishlistColor}</Label>
|
||||||
|
<ColorPicker bind:color size="sm" />
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="color" value={color || ''} />
|
||||||
|
</div>
|
||||||
|
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
|
||||||
|
{isCreating ? t.wishlist.creating : t.wishlist.createWishlist}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
function isValidImageUrl(url: string): boolean {
|
function isValidImageUrl(url: string): boolean {
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const hostname = parsedUrl.hostname.toLowerCase();
|
const hostname = parsedUrl.hostname.toLowerCase();
|
||||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url }) => {
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
const imageUrl = url.searchParams.get('url');
|
const imageUrl = url.searchParams.get('url');
|
||||||
|
|
||||||
if (!imageUrl) {
|
if (!imageUrl) {
|
||||||
return new Response('Image URL is required', { status: 400 });
|
return new Response('Image URL is required', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidImageUrl(imageUrl)) {
|
if (!isValidImageUrl(imageUrl)) {
|
||||||
return new Response('Invalid image URL', { status: 400 });
|
return new Response('Invalid image URL', { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the image with proper headers to avoid blocking
|
// Fetch the image with proper headers to avoid blocking
|
||||||
const response = await fetch(imageUrl, {
|
const response = await fetch(imageUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Referer': new URL(imageUrl).origin,
|
Referer: new URL(imageUrl).origin,
|
||||||
'Sec-Fetch-Dest': 'image',
|
'Sec-Fetch-Dest': 'image',
|
||||||
'Sec-Fetch-Mode': 'no-cors',
|
'Sec-Fetch-Mode': 'no-cors',
|
||||||
'Sec-Fetch-Site': 'cross-site'
|
'Sec-Fetch-Site': 'cross-site'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return new Response('Failed to fetch image', { status: response.status });
|
return new Response('Failed to fetch image', { status: response.status });
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||||
const imageBuffer = await response.arrayBuffer();
|
const imageBuffer = await response.arrayBuffer();
|
||||||
|
|
||||||
// Return the image with appropriate headers
|
// Return the image with appropriate headers
|
||||||
return new Response(imageBuffer, {
|
return new Response(imageBuffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
'Cache-Control': 'public, max-age=86400', // Cache for 1 day
|
||||||
'Access-Control-Allow-Origin': '*'
|
'Access-Control-Allow-Origin': '*'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Image proxy error:', error);
|
console.error('Image proxy error:', error);
|
||||||
return new Response('Failed to proxy image', { status: 500 });
|
return new Response('Failed to proxy image', { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,220 +2,242 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
function isValidUrl(urlString: string): boolean {
|
function isValidUrl(urlString: string): boolean {
|
||||||
try {
|
try {
|
||||||
const url = new URL(urlString);
|
const url = new URL(urlString);
|
||||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const hostname = url.hostname.toLowerCase();
|
const hostname = url.hostname.toLowerCase();
|
||||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
const { url } = await request.json();
|
const { url } = await request.json();
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return json({ error: 'URL is required' }, { status: 400 });
|
return json({ error: 'URL is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidUrl(url)) {
|
if (!isValidUrl(url)) {
|
||||||
return json({ error: 'Invalid URL' }, { status: 400 });
|
return json({ error: 'Invalid URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent':
|
'User-Agent':
|
||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
Accept:
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
'Accept-Language': 'en-US,en;q=0.9',
|
||||||
'Cache-Control': 'no-cache',
|
'Accept-Encoding': 'gzip, deflate, br',
|
||||||
'Pragma': 'no-cache'
|
'Cache-Control': 'no-cache',
|
||||||
}
|
Pragma: 'no-cache'
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return json({ error: 'Failed to fetch URL' }, { status: 400 });
|
return json({ error: 'Failed to fetch URL' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
const baseUrl = new URL(url);
|
const baseUrl = new URL(url);
|
||||||
const origin = baseUrl.origin;
|
const origin = baseUrl.origin;
|
||||||
|
|
||||||
const imageUrls: string[] = [];
|
const imageUrls: string[] = [];
|
||||||
|
|
||||||
function toAbsoluteUrl(imgUrl: string): string {
|
function toAbsoluteUrl(imgUrl: string): string {
|
||||||
if (imgUrl.startsWith('http')) {
|
if (imgUrl.startsWith('http')) {
|
||||||
return imgUrl;
|
return imgUrl;
|
||||||
}
|
}
|
||||||
if (imgUrl.startsWith('//')) {
|
if (imgUrl.startsWith('//')) {
|
||||||
return `https:${imgUrl}`;
|
return `https:${imgUrl}`;
|
||||||
}
|
}
|
||||||
if (imgUrl.startsWith('/')) {
|
if (imgUrl.startsWith('/')) {
|
||||||
return `${origin}${imgUrl}`;
|
return `${origin}${imgUrl}`;
|
||||||
}
|
}
|
||||||
return `${origin}/${imgUrl}`;
|
return `${origin}/${imgUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLikelyProductImage(url: string): boolean {
|
function isLikelyProductImage(url: string): boolean {
|
||||||
const lower = url.toLowerCase();
|
const lower = url.toLowerCase();
|
||||||
const badPatterns = [
|
const badPatterns = [
|
||||||
'logo', 'icon', 'sprite', 'favicon', 'banner', 'footer',
|
'logo',
|
||||||
'header', 'background', 'pattern', 'placeholder', 'thumbnail-small',
|
'icon',
|
||||||
'btn', 'button', 'menu', 'nav', 'navigation', 'social',
|
'sprite',
|
||||||
'instagram', 'facebook', 'twitter', 'linkedin', 'pinterest'
|
'favicon',
|
||||||
];
|
'banner',
|
||||||
if (badPatterns.some(pattern => lower.includes(pattern))) {
|
'footer',
|
||||||
return false;
|
'header',
|
||||||
}
|
'background',
|
||||||
if (url.endsWith('.svg')) {
|
'pattern',
|
||||||
return false;
|
'placeholder',
|
||||||
}
|
'thumbnail-small',
|
||||||
if (lower.includes('data:image')) {
|
'btn',
|
||||||
return false;
|
'button',
|
||||||
}
|
'menu',
|
||||||
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
|
'nav',
|
||||||
return false;
|
'navigation',
|
||||||
}
|
'social',
|
||||||
return true;
|
'instagram',
|
||||||
}
|
'facebook',
|
||||||
|
'twitter',
|
||||||
|
'linkedin',
|
||||||
|
'pinterest'
|
||||||
|
];
|
||||||
|
if (badPatterns.some((pattern) => lower.includes(pattern))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.endsWith('.svg')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lower.includes('data:image')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lower.includes('loading') || lower.includes('spinner') || lower.includes('skeleton')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let match;
|
let match;
|
||||||
|
|
||||||
// Priority 1: OpenGraph and Twitter meta tags (main product image)
|
// Priority 1: OpenGraph and Twitter meta tags (main product image)
|
||||||
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
|
const ogImageRegex = /<meta[^>]+property=["']og:image["'][^>]+content=["']([^"'>]+)["']/gi;
|
||||||
const twitterImageRegex = /<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
|
const twitterImageRegex =
|
||||||
|
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"'>]+)["']/gi;
|
||||||
|
|
||||||
while ((match = ogImageRegex.exec(html)) !== null) {
|
while ((match = ogImageRegex.exec(html)) !== null) {
|
||||||
const url = toAbsoluteUrl(match[1]);
|
const url = toAbsoluteUrl(match[1]);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while ((match = twitterImageRegex.exec(html)) !== null) {
|
while ((match = twitterImageRegex.exec(html)) !== null) {
|
||||||
const url = toAbsoluteUrl(match[1]);
|
const url = toAbsoluteUrl(match[1]);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Look for JSON-LD structured data (very common in modern e-commerce)
|
// Priority 2: Look for JSON-LD structured data (very common in modern e-commerce)
|
||||||
const jsonLdRegex = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
const jsonLdRegex =
|
||||||
while ((match = jsonLdRegex.exec(html)) !== null) {
|
/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
||||||
try {
|
while ((match = jsonLdRegex.exec(html)) !== null) {
|
||||||
const jsonStr = match[1];
|
try {
|
||||||
const jsonData = JSON.parse(jsonStr);
|
const jsonStr = match[1];
|
||||||
|
const jsonData = JSON.parse(jsonStr);
|
||||||
|
|
||||||
function extractImages(obj: any, results: Set<string>) {
|
function extractImages(obj: unknown, results: Set<string>) {
|
||||||
if (!obj || typeof obj !== 'object') return;
|
if (!obj || typeof obj !== 'object') return;
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
obj.forEach((item: any) => extractImages(item, results));
|
obj.forEach((item: unknown) => extractImages(item, results));
|
||||||
} else {
|
} else {
|
||||||
for (const key in obj) {
|
const record = obj as Record<string, unknown>;
|
||||||
if (key === 'image' || key === 'thumbnail' || key === 'url') {
|
for (const key in record) {
|
||||||
const val = obj[key];
|
if (key === 'image' || key === 'thumbnail' || key === 'url') {
|
||||||
if (typeof val === 'string') {
|
const val = record[key];
|
||||||
const url = toAbsoluteUrl(val);
|
if (typeof val === 'string') {
|
||||||
if (isLikelyProductImage(url)) {
|
const url = toAbsoluteUrl(val);
|
||||||
results.add(url);
|
if (isLikelyProductImage(url)) {
|
||||||
}
|
results.add(url);
|
||||||
}
|
}
|
||||||
if (Array.isArray(val)) {
|
}
|
||||||
val.forEach((item: any) => {
|
if (Array.isArray(val)) {
|
||||||
if (typeof item === 'string') {
|
val.forEach((item: unknown) => {
|
||||||
const url = toAbsoluteUrl(item);
|
if (typeof item === 'string') {
|
||||||
if (isLikelyProductImage(url)) {
|
const url = toAbsoluteUrl(item);
|
||||||
results.add(url);
|
if (isLikelyProductImage(url)) {
|
||||||
}
|
results.add(url);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
} else if (typeof obj[key] === 'object') {
|
}
|
||||||
extractImages(obj[key], results);
|
} else if (typeof record[key] === 'object') {
|
||||||
}
|
extractImages(record[key], results);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const jsonImages = new Set<string>();
|
const jsonImages = new Set<string>();
|
||||||
extractImages(jsonData, jsonImages);
|
extractImages(jsonData, jsonImages);
|
||||||
jsonImages.forEach(img => {
|
jsonImages.forEach((img) => {
|
||||||
if (!imageUrls.includes(img)) {
|
if (!imageUrls.includes(img)) {
|
||||||
imageUrls.push(img);
|
imageUrls.push(img);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// JSON parsing failed, continue
|
// JSON parsing failed, continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Look for data-image attributes (common in React/SPA)
|
// Priority 3: Look for data-image attributes (common in React/SPA)
|
||||||
const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
|
const dataImageRegex = /<[^>]+data-image=["']([^"'>]+)["']/gi;
|
||||||
while ((match = dataImageRegex.exec(html)) !== null) {
|
while ((match = dataImageRegex.exec(html)) !== null) {
|
||||||
const url = toAbsoluteUrl(match[1]);
|
const url = toAbsoluteUrl(match[1]);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: srcset attributes (responsive images)
|
// Priority 4: srcset attributes (responsive images)
|
||||||
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
|
const srcsetRegex = /<img[^>]+srcset=["']([^"'>]+)["']/gi;
|
||||||
while ((match = srcsetRegex.exec(html)) !== null) {
|
while ((match = srcsetRegex.exec(html)) !== null) {
|
||||||
const srcsetValue = match[1];
|
const srcsetValue = match[1];
|
||||||
const srcsetUrls = srcsetValue.split(',').map((s) => {
|
const srcsetUrls = srcsetValue.split(',').map((s) => {
|
||||||
const parts = s.trim().split(/\s+/);
|
const parts = s.trim().split(/\s+/);
|
||||||
return parts[0];
|
return parts[0];
|
||||||
});
|
});
|
||||||
for (const srcsetUrl of srcsetUrls) {
|
for (const srcsetUrl of srcsetUrls) {
|
||||||
const url = toAbsoluteUrl(srcsetUrl);
|
const url = toAbsoluteUrl(srcsetUrl);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 5: data-src attributes (lazy loaded)
|
// Priority 5: data-src attributes (lazy loaded)
|
||||||
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
|
const dataSrcRegex = /<img[^>]+data-src=["']([^"'>]+)["']/gi;
|
||||||
while ((match = dataSrcRegex.exec(html)) !== null) {
|
while ((match = dataSrcRegex.exec(html)) !== null) {
|
||||||
const url = toAbsoluteUrl(match[1]);
|
const url = toAbsoluteUrl(match[1]);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 6: Regular img src attributes
|
// Priority 6: Regular img src attributes
|
||||||
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
|
const imgRegex = /<img[^>]+src=["']([^"'>]+)["']/gi;
|
||||||
while ((match = imgRegex.exec(html)) !== null) {
|
while ((match = imgRegex.exec(html)) !== null) {
|
||||||
const url = toAbsoluteUrl(match[1]);
|
const url = toAbsoluteUrl(match[1]);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url)) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 7: Background images in style attributes (common in some e-commerce)
|
// Priority 7: Background images in style attributes (common in some e-commerce)
|
||||||
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi;
|
const bgImageRegex = /background(-image)?:\s*url\(["']?([^"')]*)["']?/gi;
|
||||||
while ((match = bgImageRegex.exec(html)) !== null) {
|
while ((match = bgImageRegex.exec(html)) !== null) {
|
||||||
const url = toAbsoluteUrl(match[1]);
|
const url = toAbsoluteUrl(match[1]);
|
||||||
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) {
|
if (isLikelyProductImage(url) && !imageUrls.includes(url) && !url.startsWith('data:')) {
|
||||||
imageUrls.push(url);
|
imageUrls.push(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final filtering: remove very long URLs and duplicates
|
// Final filtering: remove very long URLs and duplicates
|
||||||
const finalImages = [...new Set(imageUrls)].filter(url => {
|
const finalImages = [...new Set(imageUrls)].filter((url) => {
|
||||||
return url.length < 2000 && isLikelyProductImage(url);
|
return url.length < 2000 && isLikelyProductImage(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({ images: finalImages.slice(0, 30) });
|
return json({ images: finalImages.slice(0, 30) });
|
||||||
} catch (error) {
|
} catch {
|
||||||
return json({ error: 'Failed to scrape images' }, { status: 500 });
|
return json({ error: 'Failed to scrape images' }, { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,35 @@
|
|||||||
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/server/schema';
|
import { wishlists } from '$lib/db/schema';
|
||||||
import { eq, or } from 'drizzle-orm';
|
import { eq, or } from 'drizzle-orm';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const { token } = params;
|
const { token } = params;
|
||||||
|
|
||||||
// Find wishlist by either ownerToken or publicToken
|
// Find wishlist by either ownerToken or publicToken
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
where: or(
|
where: or(eq(wishlists.ownerToken, token), eq(wishlists.publicToken, token)),
|
||||||
eq(wishlists.ownerToken, token),
|
with: {
|
||||||
eq(wishlists.publicToken, token)
|
items: {
|
||||||
),
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
with: {
|
}
|
||||||
items: {
|
}
|
||||||
orderBy: (items, { asc }) => [asc(items.order)]
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wishlist) {
|
if (!wishlist) {
|
||||||
return json({ error: 'Wishlist not found' }, { status: 404 });
|
return json({ error: 'Wishlist not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return only the necessary fields
|
// Return only the necessary fields
|
||||||
return json({
|
return json({
|
||||||
id: wishlist.id,
|
id: wishlist.id,
|
||||||
title: wishlist.title,
|
title: wishlist.title,
|
||||||
ownerToken: wishlist.ownerToken,
|
ownerToken: wishlist.ownerToken,
|
||||||
publicToken: wishlist.publicToken,
|
publicToken: wishlist.publicToken,
|
||||||
createdAt: wishlist.createdAt,
|
createdAt: wishlist.createdAt,
|
||||||
theme: wishlist.theme,
|
theme: wishlist.theme,
|
||||||
color: wishlist.color,
|
color: wishlist.color,
|
||||||
items: wishlist.items || []
|
items: wishlist.items || []
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
|
||||||
import type { RequestHandler } from './$types';
|
|
||||||
import { db } from '$lib/server/db';
|
|
||||||
import { wishlists } from '$lib/server/schema';
|
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
|
||||||
import { sanitizeString, sanitizeColor } from '$lib/server/validation';
|
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
let title: string | null;
|
|
||||||
let description: string | null;
|
|
||||||
let color: string | null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
title = sanitizeString(body.title, 200);
|
|
||||||
description = sanitizeString(body.description, 2000);
|
|
||||||
color = sanitizeColor(body.color);
|
|
||||||
} catch (error) {
|
|
||||||
return json({ error: 'Invalid input' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return json({ error: 'Title is required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await locals.auth();
|
|
||||||
const userId = session?.user?.id || null;
|
|
||||||
|
|
||||||
const ownerToken = createId();
|
|
||||||
const publicToken = createId();
|
|
||||||
|
|
||||||
const [wishlist] = await db
|
|
||||||
.insert(wishlists)
|
|
||||||
.values({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
color,
|
|
||||||
ownerToken,
|
|
||||||
publicToken,
|
|
||||||
userId
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return json({
|
|
||||||
ownerToken,
|
|
||||||
publicToken,
|
|
||||||
id: wishlist.id,
|
|
||||||
title: wishlist.title,
|
|
||||||
createdAt: wishlist.createdAt
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,194 +1,198 @@
|
|||||||
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/server/schema';
|
import { wishlists, savedWishlists, users } from '$lib/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load: PageServerLoad = async (event) => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
const session = await event.locals.auth();
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
// Allow anonymous users to access dashboard for local wishlists
|
// Allow anonymous users to access dashboard for local wishlists
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return {
|
return {
|
||||||
user: null,
|
user: null,
|
||||||
wishlists: [],
|
wishlists: [],
|
||||||
savedWishlists: [],
|
savedWishlists: [],
|
||||||
isAuthenticated: false
|
isAuthenticated: false
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user with theme
|
// Fetch user with theme
|
||||||
const user = await db.query.users.findFirst({
|
const user = await db.query.users.findFirst({
|
||||||
where: eq(users.id, session.user.id)
|
where: eq(users.id, session.user.id)
|
||||||
});
|
});
|
||||||
|
|
||||||
const userWishlists = await db.query.wishlists.findMany({
|
const userWishlists = await db.query.wishlists.findMany({
|
||||||
where: eq(wishlists.userId, session.user.id),
|
where: eq(wishlists.userId, session.user.id),
|
||||||
with: {
|
with: {
|
||||||
items: {
|
items: {
|
||||||
orderBy: (items, { asc }) => [asc(items.order)]
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
},
|
},
|
||||||
user: true
|
user: true
|
||||||
},
|
},
|
||||||
orderBy: (wishlists, { desc }) => [desc(wishlists.createdAt)]
|
orderBy: (wishlists, { desc }) => [desc(wishlists.createdAt)]
|
||||||
});
|
});
|
||||||
|
|
||||||
const saved = await db.query.savedWishlists.findMany({
|
const saved = await db.query.savedWishlists.findMany({
|
||||||
where: eq(savedWishlists.userId, session.user.id),
|
where: eq(savedWishlists.userId, session.user.id),
|
||||||
with: {
|
with: {
|
||||||
wishlist: {
|
wishlist: {
|
||||||
with: {
|
with: {
|
||||||
items: {
|
items: {
|
||||||
orderBy: (items, { asc }) => [asc(items.order)]
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
},
|
},
|
||||||
user: true
|
user: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orderBy: (savedWishlists, { desc }) => [desc(savedWishlists.createdAt)]
|
orderBy: (savedWishlists, { desc }) => [desc(savedWishlists.createdAt)]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist)
|
// Map saved wishlists to include ownerToken from savedWishlists table (not from wishlist)
|
||||||
// This ensures users only see ownerToken if they claimed via edit link
|
// This ensures users only see ownerToken if they claimed via edit link
|
||||||
const savedWithAccess = saved.map(s => ({
|
const savedWithAccess = saved.map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
wishlist: s.wishlist ? {
|
wishlist: s.wishlist
|
||||||
...s.wishlist,
|
? {
|
||||||
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
|
...s.wishlist,
|
||||||
ownerToken: s.ownerToken,
|
// Override ownerToken: use the one stored in savedWishlists (which is null for public saves)
|
||||||
// Keep publicToken as-is for viewing
|
ownerToken: s.ownerToken,
|
||||||
publicToken: s.wishlist.publicToken
|
// Keep publicToken as-is for viewing
|
||||||
} : null
|
publicToken: s.wishlist.publicToken
|
||||||
}));
|
}
|
||||||
|
: null
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: user,
|
user: user,
|
||||||
wishlists: userWishlists,
|
wishlists: userWishlists,
|
||||||
savedWishlists: savedWithAccess,
|
savedWishlists: savedWithAccess,
|
||||||
isAuthenticated: true
|
isAuthenticated: true
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
toggleFavorite: async ({ request, locals }) => {
|
toggleFavorite: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw redirect(303, '/signin');
|
throw redirect(303, '/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const wishlistId = formData.get('wishlistId') as string;
|
const wishlistId = formData.get('wishlistId') as string;
|
||||||
const isFavorite = formData.get('isFavorite') === 'true';
|
const isFavorite = formData.get('isFavorite') === 'true';
|
||||||
|
|
||||||
if (!wishlistId) {
|
if (!wishlistId) {
|
||||||
return { success: false, error: 'Wishlist ID is required' };
|
return { success: false, error: 'Wishlist ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(wishlists)
|
await db
|
||||||
.set({ isFavorite: !isFavorite, updatedAt: new Date() })
|
.update(wishlists)
|
||||||
.where(eq(wishlists.id, wishlistId));
|
.set({ isFavorite: !isFavorite, updatedAt: new Date() })
|
||||||
|
.where(eq(wishlists.id, wishlistId));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleSavedFavorite: async ({ request, locals }) => {
|
toggleSavedFavorite: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw redirect(303, '/signin');
|
throw redirect(303, '/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const savedWishlistId = formData.get('savedWishlistId') as string;
|
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||||
const isFavorite = formData.get('isFavorite') === 'true';
|
const isFavorite = formData.get('isFavorite') === 'true';
|
||||||
|
|
||||||
if (!savedWishlistId) {
|
if (!savedWishlistId) {
|
||||||
return { success: false, error: 'Saved wishlist ID is required' };
|
return { success: false, error: 'Saved wishlist ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(savedWishlists)
|
await db
|
||||||
.set({ isFavorite: !isFavorite })
|
.update(savedWishlists)
|
||||||
.where(eq(savedWishlists.id, savedWishlistId));
|
.set({ isFavorite: !isFavorite })
|
||||||
|
.where(eq(savedWishlists.id, savedWishlistId));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
unsaveWishlist: async ({ request, locals }) => {
|
unsaveWishlist: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw redirect(303, '/signin');
|
throw redirect(303, '/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const savedWishlistId = formData.get('savedWishlistId') as string;
|
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||||
|
|
||||||
if (!savedWishlistId) {
|
if (!savedWishlistId) {
|
||||||
return { success: false, error: 'Saved wishlist ID is required' };
|
return { success: false, error: 'Saved wishlist ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(savedWishlists)
|
await db
|
||||||
.where(and(
|
.delete(savedWishlists)
|
||||||
eq(savedWishlists.id, savedWishlistId),
|
.where(
|
||||||
eq(savedWishlists.userId, session.user.id)
|
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id))
|
||||||
));
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteWishlist: async ({ request, locals }) => {
|
deleteWishlist: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw redirect(303, '/signin');
|
throw redirect(303, '/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const wishlistId = formData.get('wishlistId') as string;
|
const wishlistId = formData.get('wishlistId') as string;
|
||||||
|
|
||||||
if (!wishlistId) {
|
if (!wishlistId) {
|
||||||
return { success: false, error: 'Wishlist ID is required' };
|
return { success: false, error: 'Wishlist ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(wishlists)
|
await db
|
||||||
.where(and(
|
.delete(wishlists)
|
||||||
eq(wishlists.id, wishlistId),
|
.where(and(eq(wishlists.id, wishlistId), eq(wishlists.userId, session.user.id)));
|
||||||
eq(wishlists.userId, session.user.id)
|
|
||||||
));
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDashboardTheme: async ({ request, locals }) => {
|
updateDashboardTheme: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw redirect(303, '/signin');
|
throw redirect(303, '/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const theme = formData.get('theme') as string;
|
const theme = formData.get('theme') as string;
|
||||||
|
|
||||||
if (!theme) {
|
if (!theme) {
|
||||||
return { success: false, error: 'Theme is required' };
|
return { success: false, error: 'Theme is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(users)
|
await db
|
||||||
.set({ dashboardTheme: theme, updatedAt: new Date() })
|
.update(users)
|
||||||
.where(eq(users.id, session.user.id));
|
.set({ dashboardTheme: theme, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, session.user.id));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
updateDashboardColor: async ({ request, locals }) => {
|
updateDashboardColor: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
throw redirect(303, '/signin');
|
throw redirect(303, '/signin');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const color = formData.get('color') as string | null;
|
const color = formData.get('color') as string | null;
|
||||||
|
|
||||||
await db.update(users)
|
await db
|
||||||
.set({ dashboardColor: color, updatedAt: new Date() })
|
.update(users)
|
||||||
.where(eq(users.id, session.user.id));
|
.set({ dashboardColor: color, updatedAt: new Date() })
|
||||||
|
.where(eq(users.id, session.user.id));
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,255 +1,279 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import PageContainer from '$lib/components/layout/PageContainer.svelte';
|
import PageContainer from '$lib/components/layout/PageContainer.svelte';
|
||||||
import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte';
|
import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte';
|
||||||
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
|
import WishlistSection from '$lib/components/dashboard/WishlistSection.svelte';
|
||||||
import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte';
|
import LocalWishlistsSection from '$lib/components/dashboard/LocalWishlistsSection.svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { Star } from 'lucide-svelte';
|
import { Star } from '@lucide/svelte';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
function getInitialTheme() {
|
function getInitialTheme() {
|
||||||
if (data.isAuthenticated) {
|
if (data.isAuthenticated) {
|
||||||
return data.user?.dashboardTheme || 'none';
|
return data.user?.dashboardTheme || 'none';
|
||||||
} else {
|
} else {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem('dashboardTheme') || 'none';
|
return localStorage.getItem('dashboardTheme') || 'none';
|
||||||
}
|
}
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInitialColor() {
|
function getInitialColor() {
|
||||||
if (data.isAuthenticated) {
|
if (data.isAuthenticated) {
|
||||||
return data.user?.dashboardColor || null;
|
return data.user?.dashboardColor || null;
|
||||||
} else {
|
} else {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem('dashboardColor') || null;
|
return localStorage.getItem('dashboardColor') || null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let currentTheme = $state(getInitialTheme());
|
let currentTheme = $state(getInitialTheme());
|
||||||
let currentColor = $state(getInitialColor());
|
let currentColor = $state(getInitialColor());
|
||||||
|
|
||||||
function handleThemeUpdate(theme: string | null) {
|
function handleThemeUpdate(theme: string | null) {
|
||||||
currentTheme = theme || 'none';
|
currentTheme = theme || 'none';
|
||||||
|
|
||||||
if (!data.isAuthenticated && typeof window !== 'undefined') {
|
if (!data.isAuthenticated && typeof window !== 'undefined') {
|
||||||
localStorage.setItem('dashboardTheme', currentTheme);
|
localStorage.setItem('dashboardTheme', currentTheme);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleColorUpdate(color: string | null) {
|
function handleColorUpdate(color: string | null) {
|
||||||
currentColor = color;
|
currentColor = color;
|
||||||
|
|
||||||
if (!data.isAuthenticated && typeof window !== 'undefined') {
|
if (!data.isAuthenticated && typeof window !== 'undefined') {
|
||||||
if (color) {
|
if (color) {
|
||||||
localStorage.setItem('dashboardColor', color);
|
localStorage.setItem('dashboardColor', color);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('dashboardColor');
|
localStorage.removeItem('dashboardColor');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
const myWishlists = $derived(() => data.wishlists || []);
|
const myWishlists = $derived(() => data.wishlists || []);
|
||||||
|
|
||||||
const claimedWishlists = $derived(() => {
|
const claimedWishlists = $derived(() => {
|
||||||
return (data.savedWishlists || [])
|
return (data.savedWishlists || [])
|
||||||
.filter(saved => saved.wishlist?.ownerToken)
|
.filter((saved) => saved.wishlist?.ownerToken)
|
||||||
.map(saved => ({
|
.map((saved) => ({
|
||||||
...saved.wishlist,
|
...saved.wishlist,
|
||||||
isFavorite: saved.isFavorite,
|
isFavorite: saved.isFavorite,
|
||||||
isClaimed: true,
|
isClaimed: true,
|
||||||
savedId: saved.id
|
savedId: saved.id
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedWishlists = $derived(() => {
|
const savedWishlists = $derived(() => {
|
||||||
return (data.savedWishlists || []).filter(saved => !saved.wishlist?.ownerToken);
|
return (data.savedWishlists || []).filter((saved) => !saved.wishlist?.ownerToken);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageContainer theme={currentTheme} themeColor={currentColor}>
|
<PageContainer theme={currentTheme} themeColor={currentColor}>
|
||||||
<DashboardHeader
|
<DashboardHeader
|
||||||
userName={data.user?.name}
|
userName={data.user?.name}
|
||||||
userEmail={data.user?.email}
|
userEmail={data.user?.email}
|
||||||
dashboardTheme={currentTheme}
|
dashboardTheme={currentTheme}
|
||||||
dashboardColor={currentColor}
|
dashboardColor={currentColor}
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
onThemeUpdate={handleThemeUpdate}
|
onThemeUpdate={handleThemeUpdate}
|
||||||
onColorUpdate={handleColorUpdate}
|
onColorUpdate={handleColorUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if data.isAuthenticated}
|
{#if data.isAuthenticated}
|
||||||
<WishlistSection
|
<WishlistSection
|
||||||
title={t.dashboard.myWishlists}
|
title={t.dashboard.myWishlists}
|
||||||
description={t.dashboard.myWishlistsDescription}
|
description={t.dashboard.myWishlistsDescription}
|
||||||
items={myWishlists()}
|
items={myWishlists()}
|
||||||
emptyMessage={t.dashboard.emptyWishlists}
|
emptyMessage={t.dashboard.emptyWishlists}
|
||||||
emptyActionLabel={t.dashboard.emptyWishlistsAction}
|
emptyActionLabel={t.dashboard.emptyWishlistsAction}
|
||||||
emptyActionHref="/"
|
emptyActionHref="/"
|
||||||
showCreateButton={true}
|
showCreateButton={true}
|
||||||
fallbackColor={currentColor}
|
fallbackColor={currentColor}
|
||||||
fallbackTheme={currentTheme}
|
fallbackTheme={currentTheme}
|
||||||
>
|
>
|
||||||
{#snippet actions(wishlist, unlocked)}
|
{#snippet actions(wishlist, unlocked)}
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<form method="POST" action="?/toggleFavorite" use:enhance={() => {
|
<form
|
||||||
return async ({ update }) => {
|
method="POST"
|
||||||
await update({ reset: false });
|
action="?/toggleFavorite"
|
||||||
};
|
use:enhance={() => {
|
||||||
}}>
|
return async ({ update }) => {
|
||||||
<input type="hidden" name="wishlistId" value={wishlist.id} />
|
await update({ reset: false });
|
||||||
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
|
};
|
||||||
<Button type="submit" size="sm" variant="outline">
|
}}
|
||||||
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
>
|
||||||
</Button>
|
<input type="hidden" name="wishlistId" value={wishlist.id} />
|
||||||
</form>
|
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
|
||||||
<Button
|
<Button type="submit" size="sm" variant="outline">
|
||||||
size="sm"
|
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
|
||||||
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
</Button>
|
||||||
>
|
</form>
|
||||||
{t.dashboard.manage}
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
||||||
size="sm"
|
>
|
||||||
variant="outline"
|
{t.dashboard.manage}
|
||||||
onclick={() => {
|
</Button>
|
||||||
navigator.clipboard.writeText(
|
<Button
|
||||||
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
size="sm"
|
||||||
);
|
variant="outline"
|
||||||
}}
|
onclick={() => {
|
||||||
>
|
navigator.clipboard.writeText(
|
||||||
{t.dashboard.copyLink}
|
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
||||||
</Button>
|
);
|
||||||
{#if unlocked}
|
}}
|
||||||
<form method="POST" action="?/deleteWishlist" use:enhance={() => {
|
>
|
||||||
return async ({ update }) => {
|
{t.dashboard.copyLink}
|
||||||
await update({ reset: false });
|
</Button>
|
||||||
};
|
{#if unlocked}
|
||||||
}}>
|
<form
|
||||||
<input type="hidden" name="wishlistId" value={wishlist.id} />
|
method="POST"
|
||||||
<Button type="submit" size="sm" variant="destructive">
|
action="?/deleteWishlist"
|
||||||
{t.dashboard.delete}
|
use:enhance={() => {
|
||||||
</Button>
|
return async ({ update }) => {
|
||||||
</form>
|
await update({ reset: false });
|
||||||
{/if}
|
};
|
||||||
</div>
|
}}
|
||||||
{/snippet}
|
>
|
||||||
</WishlistSection>
|
<input type="hidden" name="wishlistId" value={wishlist.id} />
|
||||||
|
<Button type="submit" size="sm" variant="destructive">
|
||||||
|
{t.dashboard.delete}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</WishlistSection>
|
||||||
|
|
||||||
<LocalWishlistsSection
|
<LocalWishlistsSection
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
fallbackColor={currentColor}
|
fallbackColor={currentColor}
|
||||||
fallbackTheme={currentTheme}
|
fallbackTheme={currentTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WishlistSection
|
<WishlistSection
|
||||||
title={t.dashboard.claimedWishlists}
|
title={t.dashboard.claimedWishlists}
|
||||||
description={t.dashboard.claimedWishlistsDescription}
|
description={t.dashboard.claimedWishlistsDescription}
|
||||||
items={claimedWishlists()}
|
items={claimedWishlists()}
|
||||||
emptyMessage={t.dashboard.emptyClaimedWishlists}
|
emptyMessage={t.dashboard.emptyClaimedWishlists}
|
||||||
emptyDescription={t.dashboard.emptyClaimedWishlistsDescription}
|
emptyDescription={t.dashboard.emptyClaimedWishlistsDescription}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={true}
|
||||||
fallbackColor={currentColor}
|
fallbackColor={currentColor}
|
||||||
fallbackTheme={currentTheme}
|
fallbackTheme={currentTheme}
|
||||||
>
|
>
|
||||||
{#snippet actions(wishlist, unlocked)}
|
{#snippet actions(wishlist, unlocked)}
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
|
<form
|
||||||
return async ({ update }) => {
|
method="POST"
|
||||||
await update({ reset: false });
|
action="?/toggleSavedFavorite"
|
||||||
};
|
use:enhance={() => {
|
||||||
}}>
|
return async ({ update }) => {
|
||||||
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
|
await update({ reset: false });
|
||||||
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
|
};
|
||||||
<Button type="submit" size="sm" variant="outline">
|
}}
|
||||||
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
>
|
||||||
</Button>
|
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
|
||||||
</form>
|
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
|
||||||
<Button
|
<Button type="submit" size="sm" variant="outline">
|
||||||
size="sm"
|
<Star class={wishlist.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
|
||||||
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
</Button>
|
||||||
>
|
</form>
|
||||||
{t.dashboard.manage}
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
<Button
|
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
||||||
size="sm"
|
>
|
||||||
variant="outline"
|
{t.dashboard.manage}
|
||||||
onclick={() => {
|
</Button>
|
||||||
navigator.clipboard.writeText(
|
<Button
|
||||||
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
size="sm"
|
||||||
);
|
variant="outline"
|
||||||
}}
|
onclick={() => {
|
||||||
>
|
navigator.clipboard.writeText(
|
||||||
{t.dashboard.copyLink}
|
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
||||||
</Button>
|
);
|
||||||
{#if unlocked}
|
}}
|
||||||
<form method="POST" action="?/unsaveWishlist" use:enhance={() => {
|
>
|
||||||
return async ({ update }) => {
|
{t.dashboard.copyLink}
|
||||||
await update({ reset: false });
|
</Button>
|
||||||
};
|
{#if unlocked}
|
||||||
}}>
|
<form
|
||||||
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
|
method="POST"
|
||||||
<Button type="submit" size="sm" variant="destructive">
|
action="?/unsaveWishlist"
|
||||||
{t.dashboard.unclaim}
|
use:enhance={() => {
|
||||||
</Button>
|
return async ({ update }) => {
|
||||||
</form>
|
await update({ reset: false });
|
||||||
{/if}
|
};
|
||||||
</div>
|
}}
|
||||||
{/snippet}
|
>
|
||||||
</WishlistSection>
|
<input type="hidden" name="savedWishlistId" value={wishlist.savedId} />
|
||||||
|
<Button type="submit" size="sm" variant="destructive">
|
||||||
|
{t.dashboard.unclaim}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</WishlistSection>
|
||||||
|
|
||||||
<WishlistSection
|
<WishlistSection
|
||||||
title={t.dashboard.savedWishlists}
|
title={t.dashboard.savedWishlists}
|
||||||
description={t.dashboard.savedWishlistsDescription}
|
description={t.dashboard.savedWishlistsDescription}
|
||||||
items={savedWishlists()}
|
items={savedWishlists()}
|
||||||
emptyMessage={t.dashboard.emptySavedWishlists}
|
emptyMessage={t.dashboard.emptySavedWishlists}
|
||||||
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
|
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
|
||||||
fallbackColor={currentColor}
|
fallbackColor={currentColor}
|
||||||
fallbackTheme={currentTheme}
|
fallbackTheme={currentTheme}
|
||||||
hideIfEmpty={true}
|
hideIfEmpty={true}
|
||||||
>
|
>
|
||||||
{#snippet actions(saved, unlocked)}
|
{#snippet actions(saved, unlocked)}
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
|
<form
|
||||||
return async ({ update }) => {
|
method="POST"
|
||||||
await update({ reset: false });
|
action="?/toggleSavedFavorite"
|
||||||
};
|
use:enhance={() => {
|
||||||
}}>
|
return async ({ update }) => {
|
||||||
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
await update({ reset: false });
|
||||||
<input type="hidden" name="isFavorite" value={saved.isFavorite} />
|
};
|
||||||
<Button type="submit" size="sm" variant="outline">
|
}}
|
||||||
<Star class={saved.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
>
|
||||||
</Button>
|
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
||||||
</form>
|
<input type="hidden" name="isFavorite" value={saved.isFavorite} />
|
||||||
<Button
|
<Button type="submit" size="sm" variant="outline">
|
||||||
size="sm"
|
<Star class={saved.isFavorite ? 'fill-yellow-500 text-yellow-500' : ''} />
|
||||||
onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)}
|
</Button>
|
||||||
>
|
</form>
|
||||||
{t.dashboard.viewWishlist}
|
<Button
|
||||||
</Button>
|
size="sm"
|
||||||
{#if unlocked}
|
onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)}
|
||||||
<form method="POST" action="?/unsaveWishlist" use:enhance={() => {
|
>
|
||||||
return async ({ update }) => {
|
{t.dashboard.viewWishlist}
|
||||||
await update({ reset: false });
|
</Button>
|
||||||
};
|
{#if unlocked}
|
||||||
}}>
|
<form
|
||||||
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
method="POST"
|
||||||
<Button type="submit" size="sm" variant="destructive">
|
action="?/unsaveWishlist"
|
||||||
{t.dashboard.unsave}
|
use:enhance={() => {
|
||||||
</Button>
|
return async ({ update }) => {
|
||||||
</form>
|
await update({ reset: false });
|
||||||
{/if}
|
};
|
||||||
</div>
|
}}
|
||||||
{/snippet}
|
>
|
||||||
</WishlistSection>
|
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
||||||
{/if}
|
<Button type="submit" size="sm" variant="destructive">
|
||||||
|
{t.dashboard.unsave}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</WishlistSection>
|
||||||
|
{/if}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -2,23 +2,23 @@ import type { PageServerLoad } from './$types';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url }) => {
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
const registered = url.searchParams.get('registered');
|
const registered = url.searchParams.get('registered');
|
||||||
const error = url.searchParams.get('error');
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
// Determine which OAuth providers are available
|
// Determine which OAuth providers are available
|
||||||
const oauthProviders = [];
|
const oauthProviders = [];
|
||||||
|
|
||||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||||
oauthProviders.push({ id: 'google', name: 'Google' });
|
oauthProviders.push({ id: 'google', name: 'Google' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
||||||
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registered: registered === 'true',
|
registered: registered === 'true',
|
||||||
error: error,
|
error: error,
|
||||||
oauthProviders
|
oauthProviders
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,107 +1,117 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import {
|
||||||
import { Input } from '$lib/components/ui/input';
|
Card,
|
||||||
import { Label } from '$lib/components/ui/label';
|
CardContent,
|
||||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
CardDescription,
|
||||||
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
CardHeader,
|
||||||
import type { PageData } from './$types';
|
CardTitle
|
||||||
import { signIn } from '@auth/sveltekit/client';
|
} from '$lib/components/ui/card';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { signIn } from '@auth/sveltekit/client';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
isSubmitting = true;
|
isSubmitting = true;
|
||||||
|
|
||||||
const formData = new FormData(e.target as HTMLFormElement);
|
const formData = new FormData(e.target as HTMLFormElement);
|
||||||
const username = formData.get('username') as string;
|
const username = formData.get('username') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signIn('credentials', {
|
await signIn('credentials', {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
callbackUrl: '/dashboard'
|
callbackUrl: '/dashboard'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Sign in error:', error);
|
console.error('Sign in error:', error);
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting = false;
|
isSubmitting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-4">
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
|
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<Card class="w-full max-w-md">
|
<Card class="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-2xl">{t.auth.welcomeBack}</CardTitle>
|
<CardTitle class="text-2xl">{t.auth.welcomeBack}</CardTitle>
|
||||||
<CardDescription>{t.auth.signInPrompt}</CardDescription>
|
<CardDescription>{t.auth.signInPrompt}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
{#if data.registered}
|
{#if data.registered}
|
||||||
<div class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded">
|
<div
|
||||||
Account created successfully! Please sign in.
|
class="bg-green-50 border border-green-200 text-green-700 dark:bg-green-950 dark:border-green-800 dark:text-green-300 px-4 py-3 rounded"
|
||||||
</div>
|
>
|
||||||
{/if}
|
Account created successfully! Please sign in.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
<div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
|
<div
|
||||||
Invalid username or password
|
class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded"
|
||||||
</div>
|
>
|
||||||
{/if}
|
Invalid username or password
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form onsubmit={handleSubmit} class="space-y-4">
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="username">{t.form.username}</Label>
|
<Label for="username">{t.form.username}</Label>
|
||||||
<Input id="username" name="username" type="text" required />
|
<Input id="username" name="username" type="text" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="password">{t.form.password}</Label>
|
<Label for="password">{t.form.password}</Label>
|
||||||
<Input id="password" name="password" type="password" required />
|
<Input id="password" name="password" type="password" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" class="w-full" disabled={isSubmitting}>
|
<Button type="submit" class="w-full" disabled={isSubmitting}>
|
||||||
{isSubmitting ? t.auth.signingIn : t.auth.signIn}
|
{isSubmitting ? t.auth.signingIn : t.auth.signIn}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if data.oauthProviders.length > 0}
|
{#if data.oauthProviders.length > 0}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-0 flex items-center">
|
<div class="absolute inset-0 flex items-center">
|
||||||
<span class="w-full border-t"></span>
|
<span class="w-full border-t"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex justify-center text-xs uppercase">
|
<div class="relative flex justify-center text-xs uppercase">
|
||||||
<span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span>
|
<span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each data.oauthProviders as provider}
|
{#each data.oauthProviders as provider (provider.id)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
||||||
>
|
>
|
||||||
{t.auth.signIn} with {provider.name}
|
{t.auth.signIn} with {provider.name}
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="text-center text-sm text-muted-foreground">
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
{t.auth.dontHaveAccount}
|
{t.auth.dontHaveAccount}
|
||||||
<a href="/signup" class="text-primary hover:underline">{t.auth.signUp}</a>
|
<a href="/signup" class="text-primary hover:underline">{t.auth.signUp}</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,75 +1,76 @@
|
|||||||
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 } from '$lib/server/schema';
|
import { users, type NewUser } from '$lib/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { sanitizeString, sanitizeUsername } from '$lib/server/validation';
|
import { sanitizeString, sanitizeUsername } from '$lib/server/validation';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
// Determine which OAuth providers are available
|
// Determine which OAuth providers are available
|
||||||
const oauthProviders = [];
|
const oauthProviders = [];
|
||||||
|
|
||||||
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||||
oauthProviders.push({ id: 'google', name: 'Google' });
|
oauthProviders.push({ id: 'google', name: 'Google' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
||||||
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
oauthProviders
|
oauthProviders
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request }) => {
|
default: async ({ request }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const name = formData.get('name') as string;
|
const name = formData.get('name') as string;
|
||||||
const username = formData.get('username') as string;
|
const username = formData.get('username') as string;
|
||||||
const password = formData.get('password') as string;
|
const password = formData.get('password') as string;
|
||||||
const confirmPassword = formData.get('confirmPassword') as string;
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
|
||||||
let sanitizedUsername: string;
|
let sanitizedUsername: string;
|
||||||
let sanitizedName: string | null;
|
let sanitizedName: string | null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sanitizedName = sanitizeString(name, 100);
|
sanitizedName = sanitizeString(name, 100);
|
||||||
sanitizedUsername = sanitizeUsername(username);
|
sanitizedUsername = sanitizeUsername(username);
|
||||||
} catch (error) {
|
} catch {
|
||||||
return fail(400, { error: 'Invalid input', name, username });
|
return fail(400, { error: 'Invalid input', name, username });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sanitizedName) {
|
if (!sanitizedName) {
|
||||||
return fail(400, { error: 'Name is required', name, username });
|
return fail(400, { error: 'Name is required', name, username });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
if (!password || password.length < 8) {
|
||||||
return fail(400, { error: 'Password must be at least 8 characters', name, username });
|
return fail(400, { error: 'Password must be at least 8 characters', name, username });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
if (password !== confirmPassword) {
|
||||||
return fail(400, { error: 'Passwords do not match', name, username });
|
return fail(400, { error: 'Passwords do not match', name, username });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingUser = await db.query.users.findFirst({
|
const existingUser = await db.query.users.findFirst({
|
||||||
where: eq(users.username, sanitizedUsername)
|
where: eq(users.username, sanitizedUsername)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return fail(400, { error: 'Username already taken', name, username });
|
return fail(400, { error: 'Username already taken', name, username });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 14);
|
const hashedPassword = await bcrypt.hash(password, 14);
|
||||||
|
|
||||||
await db.insert(users).values({
|
const newUser: NewUser = {
|
||||||
name: sanitizedName,
|
name: sanitizedName,
|
||||||
username: sanitizedUsername,
|
username: sanitizedUsername,
|
||||||
password: hashedPassword
|
password: hashedPassword
|
||||||
});
|
};
|
||||||
|
await db.insert(users).values(newUser);
|
||||||
|
|
||||||
throw redirect(303, '/signin?registered=true');
|
throw redirect(303, '/signin?registered=true');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +1,100 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import {
|
||||||
import { Input } from '$lib/components/ui/input';
|
Card,
|
||||||
import { Label } from '$lib/components/ui/label';
|
CardContent,
|
||||||
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
CardDescription,
|
||||||
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
CardHeader,
|
||||||
import type { ActionData, PageData } from './$types';
|
CardTitle
|
||||||
import { signIn } from '@auth/sveltekit/client';
|
} from '$lib/components/ui/card';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { signIn } from '@auth/sveltekit/client';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
let { form, data }: { form: ActionData; data: PageData } = $props();
|
let { form, data }: { form: ActionData; data: PageData } = $props();
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-4">
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
|
<div class="absolute top-4 right-4 flex items-center gap-1 sm:gap-2">
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<Card class="w-full max-w-md">
|
<Card class="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-2xl">{t.auth.createAccount}</CardTitle>
|
<CardTitle class="text-2xl">{t.auth.createAccount}</CardTitle>
|
||||||
<CardDescription>{t.auth.signUpPrompt}</CardDescription>
|
<CardDescription>{t.auth.signUpPrompt}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
{#if form?.error}
|
{#if form?.error}
|
||||||
<div class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded">
|
<div
|
||||||
{form.error}
|
class="bg-red-50 border border-red-200 text-red-700 dark:bg-red-950 dark:border-red-800 dark:text-red-300 px-4 py-3 rounded"
|
||||||
</div>
|
>
|
||||||
{/if}
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<form method="POST" class="space-y-4">
|
<form method="POST" class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="name">{t.form.name}</Label>
|
<Label for="name">{t.form.name}</Label>
|
||||||
<Input id="name" name="name" type="text" required value={form?.name || ''} />
|
<Input id="name" name="name" type="text" required value={form?.name || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="username">{t.form.username}</Label>
|
<Label for="username">{t.form.username}</Label>
|
||||||
<Input id="username" name="username" type="text" required value={form?.username || ''} />
|
<Input id="username" name="username" type="text" required value={form?.username || ''} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="password">{t.form.password}</Label>
|
<Label for="password">{t.form.password}</Label>
|
||||||
<Input id="password" name="password" type="password" required minlength={8} />
|
<Input id="password" name="password" type="password" required minlength={8} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="confirmPassword">{t.form.confirmPassword}</Label>
|
<Label for="confirmPassword">{t.form.confirmPassword}</Label>
|
||||||
<Input id="confirmPassword" name="confirmPassword" type="password" required minlength={8} />
|
<Input
|
||||||
</div>
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" class="w-full">{t.auth.signUp}</Button>
|
<Button type="submit" class="w-full">{t.auth.signUp}</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if data.oauthProviders.length > 0}
|
{#if data.oauthProviders.length > 0}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-0 flex items-center">
|
<div class="absolute inset-0 flex items-center">
|
||||||
<span class="w-full border-t"></span>
|
<span class="w-full border-t"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex justify-center text-xs uppercase">
|
<div class="relative flex justify-center text-xs uppercase">
|
||||||
<span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span>
|
<span class="bg-card px-2 text-muted-foreground">{t.auth.continueWith}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each data.oauthProviders as provider}
|
{#each data.oauthProviders as provider (provider.id)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
||||||
>
|
>
|
||||||
{t.auth.signUp} with {provider.name}
|
{t.auth.signUp} with {provider.name}
|
||||||
</Button>
|
</Button>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="text-center text-sm text-muted-foreground">
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
{t.auth.alreadyHaveAccount}
|
{t.auth.alreadyHaveAccount}
|
||||||
<a href="/signin" class="text-primary hover:underline">{t.auth.signIn}</a>
|
<a href="/signin" class="text-primary hover:underline">{t.auth.signIn}</a>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,186 +1,178 @@
|
|||||||
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 } from '$lib/server/schema';
|
import { wishlists, items, reservations, savedWishlists, type NewSavedWishlist } from '$lib/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
where: eq(wishlists.publicToken, params.token),
|
where: eq(wishlists.publicToken, params.token),
|
||||||
with: {
|
with: {
|
||||||
items: {
|
items: {
|
||||||
orderBy: (items, { asc }) => [asc(items.order)],
|
orderBy: (items, { asc }) => [asc(items.order)],
|
||||||
with: {
|
with: {
|
||||||
reservations: true
|
reservations: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!wishlist) {
|
if (!wishlist) {
|
||||||
throw error(404, 'Wishlist not found');
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
let isSaved = false;
|
let isSaved = false;
|
||||||
let isClaimed = false;
|
let isClaimed = false;
|
||||||
let savedWishlistId: string | null = null;
|
let savedWishlistId: string | null = null;
|
||||||
|
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
const saved = await db.query.savedWishlists.findFirst({
|
const saved = await db.query.savedWishlists.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(savedWishlists.userId, session.user.id),
|
eq(savedWishlists.userId, session.user.id),
|
||||||
eq(savedWishlists.wishlistId, wishlist.id)
|
eq(savedWishlists.wishlistId, wishlist.id)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
isSaved = !!saved;
|
isSaved = !!saved;
|
||||||
isClaimed = !!saved?.ownerToken;
|
isClaimed = !!saved?.ownerToken;
|
||||||
savedWishlistId = saved?.id || null;
|
savedWishlistId = saved?.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wishlist,
|
wishlist,
|
||||||
isSaved,
|
isSaved,
|
||||||
isClaimed,
|
isClaimed,
|
||||||
savedWishlistId,
|
savedWishlistId,
|
||||||
isAuthenticated: !!session?.user,
|
isAuthenticated: !!session?.user,
|
||||||
currentUserId: session?.user?.id || null
|
currentUserId: session?.user?.id || null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
reserve: async ({ request, locals }) => {
|
reserve: async ({ request, locals }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const itemId = formData.get('itemId') as string;
|
const itemId = formData.get('itemId') as string;
|
||||||
const reserverName = formData.get('reserverName') as string;
|
const reserverName = formData.get('reserverName') as string;
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
return { success: false, error: 'Item ID is required' };
|
return { success: false, error: 'Item ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
const existingReservation = await db.query.reservations.findFirst({
|
const existingReservation = await db.query.reservations.findFirst({
|
||||||
where: eq(reservations.itemId, itemId)
|
where: eq(reservations.itemId, itemId)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingReservation) {
|
if (existingReservation) {
|
||||||
return { success: false, error: 'This item is already reserved' };
|
return { success: false, error: 'This item is already reserved' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.insert(reservations).values({
|
await tx.insert(reservations).values({
|
||||||
itemId,
|
itemId,
|
||||||
userId: session?.user?.id || null,
|
userId: session?.user?.id || null,
|
||||||
reserverName: reserverName?.trim() || null
|
reserverName: reserverName?.trim() || null
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx
|
await tx.update(items).set({ isReserved: true }).where(eq(items.id, itemId));
|
||||||
.update(items)
|
});
|
||||||
.set({ isReserved: true })
|
|
||||||
.where(eq(items.id, itemId));
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
unreserve: async ({ request, locals }) => {
|
unreserve: async ({ request, locals }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const itemId = formData.get('itemId') as string;
|
const itemId = formData.get('itemId') as string;
|
||||||
|
|
||||||
if (!itemId) {
|
if (!itemId) {
|
||||||
return { success: false, error: 'Item ID is required' };
|
return { success: false, error: 'Item ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
const reservation = await db.query.reservations.findFirst({
|
const reservation = await db.query.reservations.findFirst({
|
||||||
where: eq(reservations.itemId, itemId)
|
where: eq(reservations.itemId, itemId)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
return { success: false, error: 'Reservation not found' };
|
return { success: false, error: 'Reservation not found' };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reservation.userId) {
|
if (reservation.userId) {
|
||||||
if (!session?.user?.id || session.user.id !== reservation.userId) {
|
if (!session?.user?.id || session.user.id !== reservation.userId) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: 'You can only cancel your own reservations'
|
error: 'You can only cancel your own reservations'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
||||||
|
|
||||||
await tx
|
await tx.update(items).set({ isReserved: false }).where(eq(items.id, itemId));
|
||||||
.update(items)
|
});
|
||||||
.set({ isReserved: false })
|
|
||||||
.where(eq(items.id, itemId));
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
saveWishlist: async ({ request, locals, params }) => {
|
saveWishlist: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'You must be logged in to save wishlists' };
|
return { success: false, error: 'You must be logged in to save wishlists' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const wishlistId = formData.get('wishlistId') as string;
|
const wishlistId = formData.get('wishlistId') as string;
|
||||||
|
|
||||||
if (!wishlistId) {
|
if (!wishlistId) {
|
||||||
return { success: false, error: 'Wishlist ID is required' };
|
return { success: false, error: 'Wishlist ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.query.savedWishlists.findFirst({
|
const existing = await db.query.savedWishlists.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(savedWishlists.userId, session.user.id),
|
eq(savedWishlists.userId, session.user.id),
|
||||||
eq(savedWishlists.wishlistId, wishlistId)
|
eq(savedWishlists.wishlistId, wishlistId)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return { success: false, error: 'Wishlist already saved' };
|
return { success: false, error: 'Wishlist already saved' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save without ownerToken - user is accessing via public link, so no edit access
|
// Save without ownerToken - user is accessing via public link, so no edit access
|
||||||
await db.insert(savedWishlists).values({
|
const newSavedWishlist: NewSavedWishlist = {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
wishlistId,
|
wishlistId,
|
||||||
ownerToken: null // Explicitly set to null - no edit access from reservation view
|
ownerToken: null // Explicitly set to null - no edit access from reservation view
|
||||||
});
|
};
|
||||||
|
await db.insert(savedWishlists).values(newSavedWishlist);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
unsaveWishlist: async ({ request, locals }) => {
|
unsaveWishlist: async ({ request, locals }) => {
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return { success: false, error: 'You must be logged in' };
|
return { success: false, error: 'You must be logged in' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const savedWishlistId = formData.get('savedWishlistId') as string;
|
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||||
|
|
||||||
if (!savedWishlistId) {
|
if (!savedWishlistId) {
|
||||||
return { success: false, error: 'Saved wishlist ID is required' };
|
return { success: false, error: 'Saved wishlist ID is required' };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(savedWishlists)
|
.delete(savedWishlists)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(savedWishlists.id, savedWishlistId), eq(savedWishlists.userId, session.user.id))
|
||||||
eq(savedWishlists.id, savedWishlistId),
|
);
|
||||||
eq(savedWishlists.userId, session.user.id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,137 +1,127 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardTitle
|
||||||
CardTitle,
|
} from '$lib/components/ui/card';
|
||||||
} from "$lib/components/ui/card";
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Button } from "$lib/components/ui/button";
|
import type { PageData } from './$types';
|
||||||
import type { PageData } from "./$types";
|
import WishlistItem from '$lib/components/wishlist/WishlistItem.svelte';
|
||||||
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
|
import ReservationButton from '$lib/components/wishlist/ReservationButton.svelte';
|
||||||
import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte";
|
import PageContainer from '$lib/components/layout/PageContainer.svelte';
|
||||||
import PageContainer from "$lib/components/layout/PageContainer.svelte";
|
import Navigation from '$lib/components/layout/Navigation.svelte';
|
||||||
import Navigation from "$lib/components/layout/Navigation.svelte";
|
import EmptyState from '$lib/components/layout/EmptyState.svelte';
|
||||||
import EmptyState from "$lib/components/layout/EmptyState.svelte";
|
import { enhance } from '$app/forms';
|
||||||
import { enhance } from "$app/forms";
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
import { getCardStyle } from "$lib/utils/colors";
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
import { languageStore } from '$lib/stores/language.svelte';
|
import SearchBar from '$lib/components/ui/SearchBar.svelte';
|
||||||
import SearchBar from "$lib/components/ui/SearchBar.svelte";
|
import ThemeCard from '$lib/components/themes/ThemeCard.svelte';
|
||||||
import ThemeCard from "$lib/components/themes/ThemeCard.svelte";
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
|
|
||||||
const t = $derived(languageStore.t);
|
const t = $derived(languageStore.t);
|
||||||
const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
|
const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
|
||||||
|
|
||||||
const filteredItems = $derived(
|
const filteredItems = $derived(
|
||||||
data.wishlist.items?.filter(item =>
|
data.wishlist.items?.filter(
|
||||||
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
(item) =>
|
||||||
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
) || []
|
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
) || []
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
|
<PageContainer maxWidth="4xl" theme={data.wishlist.theme} themeColor={data.wishlist.color}>
|
||||||
<Navigation
|
<Navigation
|
||||||
isAuthenticated={data.isAuthenticated}
|
isAuthenticated={data.isAuthenticated}
|
||||||
showDashboardLink={true}
|
showDashboardLink={true}
|
||||||
color={data.wishlist.color}
|
color={data.wishlist.color}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card style={headerCardStyle}>
|
<Card style={headerCardStyle}>
|
||||||
<CardContent class="pt-6">
|
<CardContent class="pt-6">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<CardTitle class="text-3xl">{data.wishlist.title}</CardTitle>
|
<CardTitle class="text-3xl">{data.wishlist.title}</CardTitle>
|
||||||
{#if data.wishlist.description}
|
{#if data.wishlist.description}
|
||||||
<CardDescription class="text-base"
|
<CardDescription class="text-base">{data.wishlist.description}</CardDescription>
|
||||||
>{data.wishlist.description}</CardDescription
|
{/if}
|
||||||
>
|
</div>
|
||||||
{/if}
|
{#if data.isAuthenticated}
|
||||||
</div>
|
{#if data.isClaimed}
|
||||||
{#if data.isAuthenticated}
|
<Button variant="outline" size="sm" disabled>
|
||||||
{#if data.isClaimed}
|
{t.wishlist.youClaimedThis}
|
||||||
<Button
|
</Button>
|
||||||
variant="outline"
|
{:else if data.isSaved}
|
||||||
size="sm"
|
<form method="POST" action="?/unsaveWishlist" use:enhance>
|
||||||
disabled
|
<input type="hidden" name="savedWishlistId" value={data.savedWishlistId} />
|
||||||
>
|
<Button type="submit" variant="outline" size="sm">
|
||||||
{t.wishlist.youClaimedThis}
|
{t.wishlist.unsaveWishlist}
|
||||||
</Button>
|
</Button>
|
||||||
{:else if data.isSaved}
|
</form>
|
||||||
<form method="POST" action="?/unsaveWishlist" use:enhance>
|
{:else}
|
||||||
<input
|
<form
|
||||||
type="hidden"
|
method="POST"
|
||||||
name="savedWishlistId"
|
action="?/saveWishlist"
|
||||||
value={data.savedWishlistId}
|
use:enhance={() => {
|
||||||
/>
|
return async ({ update }) => {
|
||||||
<Button type="submit" variant="outline" size="sm">
|
await update({ reset: false });
|
||||||
{t.wishlist.unsaveWishlist}
|
};
|
||||||
</Button>
|
}}
|
||||||
</form>
|
>
|
||||||
{:else}
|
<input type="hidden" name="wishlistId" value={data.wishlist.id} />
|
||||||
<form method="POST" action="?/saveWishlist" use:enhance={() => {
|
<Button type="submit" variant="outline" size="sm">
|
||||||
return async ({ update }) => {
|
{t.wishlist.saveWishlist}
|
||||||
await update({ reset: false });
|
</Button>
|
||||||
};
|
</form>
|
||||||
}}>
|
{/if}
|
||||||
<input type="hidden" name="wishlistId" value={data.wishlist.id} />
|
{:else}
|
||||||
<Button type="submit" variant="outline" size="sm">
|
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/signin')}>
|
||||||
{t.wishlist.saveWishlist}
|
{t.wishlist.signInToSave}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
{/if}
|
||||||
{/if}
|
</div>
|
||||||
{:else}
|
</CardContent>
|
||||||
<Button
|
</Card>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => (window.location.href = "/signin")}
|
|
||||||
>
|
|
||||||
{t.wishlist.signInToSave}
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{#if data.wishlist.items && data.wishlist.items.length > 0}
|
{#if data.wishlist.items && data.wishlist.items.length > 0}
|
||||||
<SearchBar bind:value={searchQuery} />
|
<SearchBar bind:value={searchQuery} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if filteredItems.length > 0}
|
{#if filteredItems.length > 0}
|
||||||
{#each filteredItems as item}
|
{#each filteredItems as item (item.id)}
|
||||||
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
|
<WishlistItem {item} theme={data.wishlist.theme} wishlistColor={data.wishlist.color}>
|
||||||
<ReservationButton
|
<ReservationButton
|
||||||
itemId={item.id}
|
itemId={item.id}
|
||||||
isReserved={item.isReserved}
|
isReserved={item.isReserved}
|
||||||
reserverName={item.reservations?.[0]?.reserverName}
|
reserverName={item.reservations?.[0]?.reserverName}
|
||||||
reservationUserId={item.reservations?.[0]?.userId}
|
reservationUserId={item.reservations?.[0]?.userId}
|
||||||
currentUserId={data.currentUserId}
|
currentUserId={data.currentUserId}
|
||||||
/>
|
/>
|
||||||
</WishlistItem>
|
</WishlistItem>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if data.wishlist.items && data.wishlist.items.length > 0}
|
{:else if data.wishlist.items && data.wishlist.items.length > 0}
|
||||||
<Card style={headerCardStyle} class="relative overflow-hidden">
|
<Card style={headerCardStyle} class="relative overflow-hidden">
|
||||||
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
|
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} />
|
||||||
<CardContent class="p-12 relative z-10">
|
<CardContent class="p-12 relative z-10">
|
||||||
<EmptyState
|
<EmptyState message="No wishes match your search." />
|
||||||
message="No wishes match your search."
|
</CardContent>
|
||||||
/>
|
</Card>
|
||||||
</CardContent>
|
{:else}
|
||||||
</Card>
|
<Card style={headerCardStyle} class="relative overflow-hidden">
|
||||||
{:else}
|
<ThemeCard
|
||||||
<Card style={headerCardStyle} class="relative overflow-hidden">
|
themeName={data.wishlist.theme}
|
||||||
<ThemeCard themeName={data.wishlist.theme} color={data.wishlist.color} showPattern={false} />
|
color={data.wishlist.color}
|
||||||
<CardContent class="p-12 relative z-10">
|
showPattern={false}
|
||||||
<EmptyState
|
/>
|
||||||
message={t.wishlist.emptyWishes}
|
<CardContent class="p-12 relative z-10">
|
||||||
/>
|
<EmptyState message={t.wishlist.emptyWishes} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
|||||||
@@ -1,314 +1,269 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error, fail } 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 } from '$lib/server/schema';
|
import { wishlists, items, savedWishlists, type NewItem, type NewSavedWishlist } from '$lib/db/schema';
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { itemSchema, updateItemSchema, wishlistSchema, reorderSchema } from '$lib/server/validation';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals }) => {
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
where: eq(wishlists.ownerToken, params.token),
|
where: eq(wishlists.ownerToken, params.token),
|
||||||
with: {
|
with: {
|
||||||
items: {
|
items: {
|
||||||
orderBy: (items, { asc }) => [asc(items.order)]
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!wishlist) {
|
if (!wishlist) {
|
||||||
throw error(404, 'Wishlist not found');
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await locals.auth();
|
const session = await locals.auth();
|
||||||
let hasClaimed = false;
|
let hasClaimed = false;
|
||||||
let isOwner = false;
|
let isOwner = false;
|
||||||
|
|
||||||
if (session?.user?.id) {
|
if (session?.user?.id) {
|
||||||
// Check if user is the owner
|
// Check if user is the owner
|
||||||
isOwner = wishlist.userId === session.user.id;
|
isOwner = wishlist.userId === session.user.id;
|
||||||
|
|
||||||
// Check if user has claimed this wishlist
|
// Check if user has claimed this wishlist
|
||||||
const savedWishlist = await db.query.savedWishlists.findFirst({
|
const savedWishlist = await db.query.savedWishlists.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
eq(savedWishlists.userId, session.user.id),
|
eq(savedWishlists.userId, session.user.id),
|
||||||
eq(savedWishlists.wishlistId, wishlist.id)
|
eq(savedWishlists.wishlistId, wishlist.id)
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
hasClaimed = !!savedWishlist;
|
hasClaimed = !!savedWishlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
wishlist,
|
wishlist,
|
||||||
publicUrl: `/wishlist/${wishlist.publicToken}`,
|
publicUrl: `/wishlist/${wishlist.publicToken}`,
|
||||||
isAuthenticated: !!session?.user,
|
isAuthenticated: !!session?.user,
|
||||||
hasClaimed,
|
hasClaimed,
|
||||||
isOwner
|
isOwner
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
addItem: async ({ params, request }) => {
|
addItem: async ({ params, request }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const title = formData.get('title') as string;
|
const rawData = Object.fromEntries(formData);
|
||||||
const description = formData.get('description') as string;
|
|
||||||
const link = formData.get('link') as string;
|
|
||||||
const imageUrl = formData.get('imageUrl') as string;
|
|
||||||
const price = formData.get('price') as string;
|
|
||||||
const currency = formData.get('currency') as string;
|
|
||||||
const color = formData.get('color') as string;
|
|
||||||
|
|
||||||
if (!title?.trim()) {
|
const result = itemSchema.safeParse(rawData);
|
||||||
return { success: false, error: 'Title is required' };
|
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),
|
||||||
with: {
|
with: { items: true }
|
||||||
items: true
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wishlist) {
|
if (!wishlist) {
|
||||||
throw error(404, 'Wishlist not found');
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the max order value and add 1
|
const maxOrder = wishlist.items.reduce((max, item) => {
|
||||||
const maxOrder = wishlist.items.reduce((max, item) => {
|
const order = Number(item.order) || 0;
|
||||||
const order = Number(item.order) || 0;
|
return order > max ? order : max;
|
||||||
return order > max ? order : max;
|
}, 0);
|
||||||
}, 0);
|
|
||||||
|
|
||||||
await db.insert(items).values({
|
const newItem: NewItem = {
|
||||||
wishlistId: wishlist.id,
|
wishlistId: wishlist.id,
|
||||||
title: title.trim(),
|
...result.data,
|
||||||
description: description?.trim() || null,
|
price: result.data.price?.toString() || null,
|
||||||
link: link?.trim() || null,
|
order: String(maxOrder + 1)
|
||||||
imageUrl: imageUrl?.trim() || null,
|
};
|
||||||
price: price ? price.trim() : null,
|
await db.insert(items).values(newItem);
|
||||||
currency: currency?.trim() || 'DKK',
|
|
||||||
color: color?.trim() || null,
|
|
||||||
order: String(maxOrder + 1)
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
updateItem: async ({ params, request }) => {
|
updateItem: async ({ params, request }) => {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const itemId = formData.get('itemId') as string;
|
const 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;
|
|
||||||
|
|
||||||
if (!itemId) {
|
const result = updateItemSchema.safeParse(rawData);
|
||||||
return { success: false, error: 'Item ID is required' };
|
if (!result.success) {
|
||||||
}
|
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
|
||||||
|
}
|
||||||
|
|
||||||
if (!title?.trim()) {
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
return { success: false, error: 'Title is required' };
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
}
|
});
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
if (!wishlist) {
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
throw error(404, 'Wishlist not found');
|
||||||
});
|
}
|
||||||
|
|
||||||
if (!wishlist) {
|
const { id, ...data } = result.data;
|
||||||
throw error(404, 'Wishlist not found');
|
await db
|
||||||
}
|
.update(items)
|
||||||
|
.set({
|
||||||
|
...data,
|
||||||
|
price: data.price?.toString() || null,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(items.id, id));
|
||||||
|
|
||||||
await db.update(items)
|
return { success: true };
|
||||||
.set({
|
},
|
||||||
title: title.trim(),
|
|
||||||
description: description?.trim() || null,
|
|
||||||
link: link?.trim() || null,
|
|
||||||
imageUrl: imageUrl?.trim() || null,
|
|
||||||
price: price ? price.trim() : null,
|
|
||||||
currency: currency?.trim() || 'DKK',
|
|
||||||
color: color?.trim() || null,
|
|
||||||
updatedAt: new Date()
|
|
||||||
})
|
|
||||||
.where(eq(items.id, itemId));
|
|
||||||
|
|
||||||
return { success: true };
|
deleteItem: async ({ params, request }) => {
|
||||||
},
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get('itemId') as string;
|
||||||
|
|
||||||
deleteItem: async ({ params, request }) => {
|
if (!itemId) {
|
||||||
const formData = await request.formData();
|
return { success: false, error: 'Item ID is required' };
|
||||||
const itemId = formData.get('itemId') as string;
|
}
|
||||||
|
|
||||||
if (!itemId) {
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
return { success: false, error: 'Item ID is required' };
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
}
|
});
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
if (!wishlist) {
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
throw error(404, 'Wishlist not found');
|
||||||
});
|
}
|
||||||
|
|
||||||
if (!wishlist) {
|
await db.delete(items).where(eq(items.id, itemId));
|
||||||
throw error(404, 'Wishlist not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(items).where(eq(items.id, itemId));
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
return { success: true };
|
reorderItems: async ({ params, request }) => {
|
||||||
},
|
const formData = await request.formData();
|
||||||
|
const itemsJson = formData.get('items') as string;
|
||||||
|
|
||||||
reorderItems: async ({ params, request }) => {
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
const formData = await request.formData();
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
const itemsJson = formData.get('items') as string;
|
});
|
||||||
|
|
||||||
if (!itemsJson) {
|
if (!wishlist) {
|
||||||
return { success: false, error: 'Items data is required' };
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
const result = reorderSchema.safeParse(JSON.parse(itemsJson || '[]'));
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
if (!result.success) {
|
||||||
});
|
return fail(400, { success: false, error: 'Invalid reorder data' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!wishlist) {
|
for (const update of result.data) {
|
||||||
throw error(404, 'Wishlist not found');
|
await db
|
||||||
}
|
.update(items)
|
||||||
|
.set({ order: String(update.order), updatedAt: new Date() })
|
||||||
|
.where(eq(items.id, update.id));
|
||||||
|
}
|
||||||
|
|
||||||
const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
for (const update of updates) {
|
deleteWishlist: async ({ params }) => {
|
||||||
await db.update(items)
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
.set({ order: String(update.order), updatedAt: new Date() })
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
.where(eq(items.id, update.id));
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
if (!wishlist) {
|
||||||
},
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
deleteWishlist: async ({ params }) => {
|
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wishlist) {
|
return { success: true, redirect: '/dashboard' };
|
||||||
throw error(404, 'Wishlist not found');
|
},
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
|
updateWishlist: async ({ params, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const rawData = Object.fromEntries(formData);
|
||||||
|
|
||||||
return { success: true, redirect: '/dashboard' };
|
// Only validate fields that are present
|
||||||
},
|
const partialSchema = wishlistSchema.partial();
|
||||||
|
const result = partialSchema.safeParse(rawData);
|
||||||
|
|
||||||
updateWishlist: async ({ params, request }) => {
|
if (!result.success) {
|
||||||
const formData = await request.formData();
|
return fail(400, { success: false, error: result.error.errors.map(e => e.message).join(', ') });
|
||||||
const color = formData.get('color');
|
}
|
||||||
const title = formData.get('title');
|
|
||||||
const description = formData.get('description');
|
|
||||||
const endDate = formData.get('endDate');
|
|
||||||
const theme = formData.get('theme');
|
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!wishlist) {
|
if (!wishlist) {
|
||||||
throw error(404, 'Wishlist not found');
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates: any = {
|
await db
|
||||||
updatedAt: new Date()
|
.update(wishlists)
|
||||||
};
|
.set({
|
||||||
|
...result.data,
|
||||||
|
updatedAt: new Date()
|
||||||
|
})
|
||||||
|
.where(eq(wishlists.id, wishlist.id));
|
||||||
|
|
||||||
if (color !== null) {
|
return { success: true };
|
||||||
updates.color = color?.toString().trim() || null;
|
},
|
||||||
}
|
|
||||||
|
|
||||||
if (title !== null) {
|
claimWishlist: async ({ params, locals }) => {
|
||||||
const titleStr = title?.toString().trim();
|
const session = await locals.auth();
|
||||||
if (!titleStr) {
|
|
||||||
return { success: false, error: 'Title is required' };
|
|
||||||
}
|
|
||||||
updates.title = titleStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description !== null) {
|
if (!session?.user?.id) {
|
||||||
updates.description = description?.toString().trim() || null;
|
throw error(401, 'You must be signed in to claim a wishlist');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endDate !== null) {
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
const endDateStr = endDate?.toString().trim();
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
updates.endDate = endDateStr ? new Date(endDateStr) : null;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (theme !== null) {
|
if (!wishlist) {
|
||||||
updates.theme = theme?.toString().trim() || 'none';
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(wishlists)
|
// Check if already claimed
|
||||||
.set(updates)
|
const existing = await db.query.savedWishlists.findFirst({
|
||||||
.where(eq(wishlists.id, wishlist.id));
|
where: and(
|
||||||
|
eq(savedWishlists.userId, session.user.id),
|
||||||
|
eq(savedWishlists.wishlistId, wishlist.id)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true };
|
if (existing) {
|
||||||
},
|
return { success: true, message: 'Already claimed' };
|
||||||
|
}
|
||||||
|
|
||||||
claimWishlist: async ({ params, locals }) => {
|
// Store the ownerToken - user is accessing via edit link, so they get edit access
|
||||||
const session = await locals.auth();
|
const newSavedWishlist: NewSavedWishlist = {
|
||||||
|
userId: session.user.id,
|
||||||
|
wishlistId: wishlist.id,
|
||||||
|
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
|
||||||
|
isFavorite: false
|
||||||
|
};
|
||||||
|
await db.insert(savedWishlists).values(newSavedWishlist);
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
return { success: true, message: 'Wishlist claimed successfully' };
|
||||||
throw error(401, 'You must be signed in to claim a wishlist');
|
},
|
||||||
}
|
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
unclaimWishlist: async ({ params, locals }) => {
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
const session = await locals.auth();
|
||||||
});
|
|
||||||
|
|
||||||
if (!wishlist) {
|
if (!session?.user?.id) {
|
||||||
throw error(404, 'Wishlist not found');
|
throw error(401, 'You must be signed in');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if already claimed
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
const existing = await db.query.savedWishlists.findFirst({
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
where: and(
|
});
|
||||||
eq(savedWishlists.userId, session.user.id),
|
|
||||||
eq(savedWishlists.wishlistId, wishlist.id)
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
if (!wishlist) {
|
||||||
return { success: true, message: 'Already claimed' };
|
throw error(404, 'Wishlist not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the ownerToken - user is accessing via edit link, so they get edit access
|
await db
|
||||||
await db.insert(savedWishlists).values({
|
.delete(savedWishlists)
|
||||||
userId: session.user.id,
|
.where(
|
||||||
wishlistId: wishlist.id,
|
and(eq(savedWishlists.userId, session.user.id), eq(savedWishlists.wishlistId, wishlist.id))
|
||||||
ownerToken: wishlist.ownerToken, // Store ownerToken to grant edit access
|
);
|
||||||
isFavorite: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, message: 'Wishlist claimed successfully' };
|
return { success: true, message: 'Wishlist unclaimed' };
|
||||||
},
|
}
|
||||||
|
|
||||||
unclaimWishlist: async ({ params, locals }) => {
|
|
||||||
const session = await locals.auth();
|
|
||||||
|
|
||||||
if (!session?.user?.id) {
|
|
||||||
throw error(401, 'You must be signed in');
|
|
||||||
}
|
|
||||||
|
|
||||||
const wishlist = await db.query.wishlists.findFirst({
|
|
||||||
where: eq(wishlists.ownerToken, params.token)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!wishlist) {
|
|
||||||
throw error(404, 'Wishlist not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.delete(savedWishlists).where(
|
|
||||||
and(
|
|
||||||
eq(savedWishlists.userId, session.user.id),
|
|
||||||
eq(savedWishlists.wishlistId, wishlist.id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { success: true, message: 'Wishlist unclaimed' };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user