initial production version
This commit is contained in:
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Database connection string
|
||||||
|
# Example: postgresql://username:password@localhost:5432/wishlist
|
||||||
|
DATABASE_URL=postgresql://user:password@localhost:5432/wishlist
|
||||||
|
|
||||||
|
# Auth.js configuration
|
||||||
|
# Generate AUTH_SECRET with: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=your-secret-key-here
|
||||||
|
AUTH_URL=http://localhost:5173
|
||||||
|
AUTH_TRUST_HOST=true
|
||||||
|
|
||||||
|
# Google OAuth (optional - for Google sign-in)
|
||||||
|
GOOGLE_CLIENT_ID=your-google-client-id
|
||||||
|
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
||||||
|
|
||||||
|
# Authentik OAuth (optional - for Authentik sign-in)
|
||||||
|
# AUTHENTIK_ISSUER should be your Authentik URL with the application slug
|
||||||
|
# Example: https://authentik.company.com/application/o/wishlist/
|
||||||
|
AUTHENTIK_CLIENT_ID=your-authentik-client-id
|
||||||
|
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
|
||||||
|
AUTHENTIK_ISSUER=https://authentik.example.com/application/o/your-app/
|
||||||
3
wishlist-app/.gitignore → .gitignore
vendored
3
wishlist-app/.gitignore → .gitignore
vendored
@@ -21,3 +21,6 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude
|
||||||
75
COOLIFY_DEPLOYMENT.md
Normal file
75
COOLIFY_DEPLOYMENT.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Coolify Deployment
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Coolify instance
|
||||||
|
- Git repository
|
||||||
|
|
||||||
|
## Database Setup
|
||||||
|
|
||||||
|
Choose one:
|
||||||
|
|
||||||
|
**Option A: Docker Compose (Recommended)**
|
||||||
|
- Database included in `docker-compose.coolify.yml`
|
||||||
|
- Skip to step 2
|
||||||
|
|
||||||
|
**Option B: Separate PostgreSQL Resource**
|
||||||
|
1. Create PostgreSQL database in Coolify
|
||||||
|
2. Note connection details
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
1. Create application in Coolify
|
||||||
|
2. Select Git repository
|
||||||
|
3. Configure:
|
||||||
|
- Build Pack: Docker Compose
|
||||||
|
- File: `./docker-compose.coolify.yml`
|
||||||
|
4. Assign domain to `app` service only (format: `http://yourdomain.com:3000`)
|
||||||
|
5. Set environment variables (if needed)
|
||||||
|
6. Deploy
|
||||||
|
|
||||||
|
### Using Dockerfile
|
||||||
|
|
||||||
|
1. Create application in Coolify
|
||||||
|
2. Select Git repository
|
||||||
|
3. Configure:
|
||||||
|
- Build Pack: Docker
|
||||||
|
- Port: `3000`
|
||||||
|
4. Add domain
|
||||||
|
5. Set environment variables:
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `AUTH_SECRET` (generate with `openssl rand -base64 32`)
|
||||||
|
- `AUTH_URL` (your domain with https)
|
||||||
|
- `GOOGLE_CLIENT_ID` (optional)
|
||||||
|
- `GOOGLE_CLIENT_SECRET` (optional)
|
||||||
|
6. Deploy
|
||||||
|
|
||||||
|
## After Deployment
|
||||||
|
|
||||||
|
Run migrations:
|
||||||
|
```bash
|
||||||
|
# In Coolify terminal or SSH
|
||||||
|
docker exec -it <container-name> bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `DATABASE_URL` - Connection string
|
||||||
|
- `AUTH_SECRET` - Random secret
|
||||||
|
- `AUTH_URL` - Your app URL
|
||||||
|
- `AUTH_TRUST_HOST` - `true`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- `GOOGLE_CLIENT_ID`
|
||||||
|
- `GOOGLE_CLIENT_SECRET`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Container crashes:** Check logs in Coolify dashboard
|
||||||
|
|
||||||
|
**Database connection:** Verify `DATABASE_URL` format
|
||||||
|
|
||||||
|
**Auth issues:** Check `AUTH_URL` matches your domain
|
||||||
57
DOCKER.md
Normal file
57
DOCKER.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Docker Guide
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Setup database
|
||||||
|
docker-compose exec app bun run db:push
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f app
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000`
|
||||||
|
|
||||||
|
## Docker Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build image
|
||||||
|
docker build -t wishlist-app .
|
||||||
|
|
||||||
|
# Run container
|
||||||
|
docker run -d \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
|
||||||
|
--name wishlist-app \
|
||||||
|
wishlist-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `NODE_ENV` - Set to `production`
|
||||||
|
- `PORT` - Default `3000`
|
||||||
|
|
||||||
|
## Database Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# docker-compose
|
||||||
|
docker-compose exec app bun run db:push
|
||||||
|
|
||||||
|
# Standalone container
|
||||||
|
docker exec -it wishlist-app bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
Production migrations:
|
||||||
|
```bash
|
||||||
|
docker exec -it wishlist-app bun run db:migrate
|
||||||
|
```
|
||||||
@@ -4,8 +4,10 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
COPY package.json bun.lockb ./
|
COPY package.json bun.lock* ./
|
||||||
|
COPY patches ./patches
|
||||||
RUN bun install --frozen-lockfile
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN bunx patch-package
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
33
LICENSE
Normal file
33
LICENSE
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2025 Rasmus Quist
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ADDITIONAL TERMS:
|
||||||
|
|
||||||
|
This software may only be used in open source projects. Any use of this
|
||||||
|
software in proprietary, closed-source, or non-open-source projects is
|
||||||
|
strictly prohibited.
|
||||||
|
|
||||||
|
For the purposes of this license, "open source project" means a project
|
||||||
|
whose source code is publicly available under an OSI-approved open source
|
||||||
|
license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Full license text: https://www.gnu.org/licenses/agpl-3.0.txt
|
||||||
64
README.md
Normal file
64
README.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Wishlist App
|
||||||
|
|
||||||
|
A wishlist application built with SvelteKit, Drizzle ORM, and PostgreSQL.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- [Bun](https://bun.sh/)
|
||||||
|
- PostgreSQL database
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Copy environment file
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your DATABASE_URL
|
||||||
|
# DATABASE_URL=postgresql://username:password@localhost:5432/wishlist
|
||||||
|
|
||||||
|
# Set up database
|
||||||
|
bun run db:push
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:5173`
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
docker-compose exec app bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000`
|
||||||
|
|
||||||
|
## Database Commands
|
||||||
|
|
||||||
|
- `bun run db:push` - Push schema to database (development)
|
||||||
|
- `bun run db:generate` - Generate migrations
|
||||||
|
- `bun run db:migrate` - Run migrations (production)
|
||||||
|
- `bun run db:studio` - Open Drizzle Studio
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- `src/lib/server/schema.ts` - Database schema
|
||||||
|
- `src/lib/server/db.ts` - Database connection
|
||||||
|
- `src/routes/` - Pages and API endpoints
|
||||||
|
- `src/auth.ts` - Authentication configuration
|
||||||
|
- `.env` - Environment variables (copy from `.env.example`)
|
||||||
|
|
||||||
|
## Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run build
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AGPL-3.0 - This project may only be used in open source projects.
|
||||||
71
SETUP.md
Normal file
71
SETUP.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Setup Guide
|
||||||
|
|
||||||
|
## Docker (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
docker-compose exec app bun run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000`
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### 1. Install PostgreSQL
|
||||||
|
|
||||||
|
Choose one:
|
||||||
|
|
||||||
|
**Local PostgreSQL:**
|
||||||
|
```bash
|
||||||
|
sudo apt install postgresql
|
||||||
|
sudo -u postgres createdb wishlist
|
||||||
|
sudo -u postgres psql
|
||||||
|
CREATE USER wishlistuser WITH PASSWORD 'password';
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE wishlist TO wishlistuser;
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
**Docker PostgreSQL:**
|
||||||
|
```bash
|
||||||
|
docker run --name wishlist-postgres \
|
||||||
|
-e POSTGRES_DB=wishlist \
|
||||||
|
-e POSTGRES_USER=wishlistuser \
|
||||||
|
-e POSTGRES_PASSWORD=password \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:16
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hosted:** Supabase, Neon, or Railway
|
||||||
|
|
||||||
|
### 2. Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your DATABASE_URL
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bun run db:push
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:5173`
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Connection errors:**
|
||||||
|
- Check PostgreSQL is running: `sudo systemctl status postgresql`
|
||||||
|
- Test connection: `psql "postgresql://user:pass@localhost:5432/wishlist"`
|
||||||
|
|
||||||
|
**Port in use:**
|
||||||
|
```bash
|
||||||
|
bun run dev -- --port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Schema changes:**
|
||||||
|
```bash
|
||||||
|
bun run db:push
|
||||||
|
```
|
||||||
@@ -5,31 +5,48 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "wishlist-app",
|
"name": "wishlist-app",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.34.3",
|
||||||
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
|
"@auth/sveltekit": "^1.11.1",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "^3.10.0",
|
||||||
"@paralleldrive/cuid2": "^3.0.4",
|
"@paralleldrive/cuid2": "^3.0.4",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"bits-ui": "^2.14.4",
|
"bits-ui": "^2.14.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"lucide-svelte": "^0.554.0",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
|
"svelte-dnd-action": "^0.9.67",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.1.1",
|
"tailwind-variants": "^3.2.2",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.4.0",
|
||||||
"@sveltejs/kit": "^2.48.5",
|
"@sveltejs/kit": "^2.48.5",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"drizzle-kit": "^0.31.7",
|
"drizzle-kit": "^0.31.7",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"svelte": "^5.43.8",
|
"svelte": "^5.43.8",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.4",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.2",
|
"vite": "^7.2.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
|
||||||
@@ -106,8 +123,12 @@
|
|||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
@@ -210,36 +231,66 @@
|
|||||||
|
|
||||||
"@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=="],
|
"@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/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||||
|
|
||||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
|
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
|
|
||||||
|
"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=="],
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
|
||||||
|
|
||||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||||
|
|
||||||
"bits-ui": ["bits-ui@2.14.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg=="],
|
"bits-ui": ["bits-ui@2.14.4", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-W6kenhnbd/YVvur+DKkaVJ6GldE53eLewur5AhUCqslYQ0vjZr8eWlOfwZnMiPB+PF5HMVqf61vXBvmyrAmPWg=="],
|
||||||
|
|
||||||
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
"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=="],
|
"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=="],
|
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||||
|
|
||||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -250,10 +301,18 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
|
||||||
@@ -266,26 +325,64 @@
|
|||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"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=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||||
|
|
||||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
|
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||||
|
|
||||||
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||||
|
|
||||||
|
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
"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=="],
|
"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=="],
|
"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": ["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=="],
|
||||||
@@ -314,10 +411,18 @@
|
|||||||
|
|
||||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"lucide-svelte": ["lucide-svelte@0.554.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-LLcpHi3SuKup0nVD1kKqo8FDZnjXJp48uST26GGh8Jcyrxqk5gmgpnvKmHsHox674UL3cPS1DCul/wFL7ybGqg=="],
|
||||||
|
|
||||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"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=="],
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
@@ -326,6 +431,20 @@
|
|||||||
|
|
||||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"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=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
@@ -336,6 +455,14 @@
|
|||||||
|
|
||||||
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||||
|
|
||||||
|
"postinstall-postinstall": ["postinstall-postinstall@2.1.0", "", {}, "sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||||
@@ -348,10 +475,20 @@
|
|||||||
|
|
||||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
"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-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=="],
|
"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": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
@@ -360,19 +497,23 @@
|
|||||||
|
|
||||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||||
|
|
||||||
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"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": ["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-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=="],
|
"svelte-toolbelt": ["svelte-toolbelt@0.10.6", "", { "dependencies": { "clsx": "^2.1.1", "runed": "^0.35.1", "style-to-object": "^1.0.8" }, "peerDependencies": { "svelte": "^5.30.2" } }, "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ=="],
|
||||||
|
|
||||||
"tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="],
|
"tabbable": ["tabbable@6.3.0", "", {}, "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ=="],
|
||||||
|
|
||||||
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
|
||||||
|
|
||||||
"tailwind-variants": ["tailwind-variants@3.1.1", "", { "peerDependencies": { "tailwind-merge": ">=3.0.0", "tailwindcss": "*" }, "optionalPeers": ["tailwind-merge"] }, "sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ=="],
|
"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=="],
|
"tailwindcss": ["tailwindcss@4.1.17", "", {}, "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q=="],
|
||||||
|
|
||||||
@@ -380,18 +521,36 @@
|
|||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"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=="],
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"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=="],
|
"vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="],
|
||||||
|
|
||||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"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=="],
|
"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=="],
|
"@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=="],
|
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||||
@@ -408,6 +567,24 @@
|
|||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"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-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-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
|
||||||
16
components.json
Normal file
16
components.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||||
|
"tailwind": {
|
||||||
|
"css": "src/app.css",
|
||||||
|
"baseColor": "slate"
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "$lib/components",
|
||||||
|
"utils": "$lib/utils",
|
||||||
|
"ui": "$lib/components/ui",
|
||||||
|
"hooks": "$lib/hooks",
|
||||||
|
"lib": "$lib"
|
||||||
|
},
|
||||||
|
"typescript": true,
|
||||||
|
"registry": "https://shadcn-svelte.com/registry"
|
||||||
|
}
|
||||||
52
docker-compose.coolify.yml
Normal file
52
docker-compose.coolify.yml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Coolify-optimized Docker Compose
|
||||||
|
# Includes both app and database - database is only exposed internally
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-wishlistuser}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-wishlistpassword}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-wishlist}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wishlistuser} -d ${POSTGRES_DB:-wishlist}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
# NOTE: No ports exposed - only accessible internally by app service
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
# Coolify will inject these from Environment Variables
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-wishlistuser}:${POSTGRES_PASSWORD:-wishlistpassword}@db:5432/${POSTGRES_DB:-wishlist}
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET}
|
||||||
|
AUTH_URL: ${AUTH_URL:-https://wish.rasmusq.com}
|
||||||
|
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-true}
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.http.routers.wishlist.rule=Host(`wish.rasmusq.com`)
|
||||||
|
- traefik.http.routers.wishlist.entryPoints=https
|
||||||
|
- traefik.http.routers.wishlist.tls=true
|
||||||
|
- traefik.http.routers.wishlist.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.wishlist.loadbalancer.server.port=3000
|
||||||
|
# Forward headers for Auth.js behind reverse proxy
|
||||||
|
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Proto=https
|
||||||
|
- traefik.http.middlewares.wishlist-headers.headers.customrequestheaders.X-Forwarded-Host=wish.rasmusq.com
|
||||||
|
- traefik.http.routers.wishlist.middlewares=wishlist-headers
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -27,6 +25,11 @@ services:
|
|||||||
DATABASE_URL: postgresql://wishlistuser:wishlistpassword@db:5432/wishlist
|
DATABASE_URL: postgresql://wishlistuser:wishlistpassword@db:5432/wishlist
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:-change-me-in-production}
|
||||||
|
AUTH_URL: ${AUTH_URL:-http://localhost:3000}
|
||||||
|
AUTH_TRUST_HOST: true
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
593
drizzle/meta/0001_snapshot.json
Normal file
593
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
{
|
||||||
|
"id": "d5cb75d0-dd76-4feb-87ce-6b2e8b3aaae1",
|
||||||
|
"prevId": "94fab923-b1d4-447e-b367-5ee7b7894b14",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"providerAccountId": {
|
||||||
|
"name": "providerAccountId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "numeric",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_userId_user_id_fk": {
|
||||||
|
"name": "account_userId_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"account_provider_providerAccountId_pk": {
|
||||||
|
"name": "account_provider_providerAccountId_pk",
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"providerAccountId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.items": {
|
||||||
|
"name": "items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"wishlist_id": {
|
||||||
|
"name": "wishlist_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"name": "link",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"name": "price",
|
||||||
|
"type": "numeric(10, 2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"name": "currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'DKK'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'medium'"
|
||||||
|
},
|
||||||
|
"is_reserved": {
|
||||||
|
"name": "is_reserved",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"items_wishlist_id_wishlists_id_fk": {
|
||||||
|
"name": "items_wishlist_id_wishlists_id_fk",
|
||||||
|
"tableFrom": "items",
|
||||||
|
"tableTo": "wishlists",
|
||||||
|
"columnsFrom": [
|
||||||
|
"wishlist_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.reservations": {
|
||||||
|
"name": "reservations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reserver_name": {
|
||||||
|
"name": "reserver_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reservations_item_id_items_id_fk": {
|
||||||
|
"name": "reservations_item_id_items_id_fk",
|
||||||
|
"tableFrom": "reservations",
|
||||||
|
"tableTo": "items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.saved_wishlists": {
|
||||||
|
"name": "saved_wishlists",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"wishlist_id": {
|
||||||
|
"name": "wishlist_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"name": "nickname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"saved_wishlists_user_id_user_id_fk": {
|
||||||
|
"name": "saved_wishlists_user_id_user_id_fk",
|
||||||
|
"tableFrom": "saved_wishlists",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"saved_wishlists_wishlist_id_wishlists_id_fk": {
|
||||||
|
"name": "saved_wishlists_wishlist_id_wishlists_id_fk",
|
||||||
|
"tableFrom": "saved_wishlists",
|
||||||
|
"tableTo": "wishlists",
|
||||||
|
"columnsFrom": [
|
||||||
|
"wishlist_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"sessionToken": {
|
||||||
|
"name": "sessionToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verificationToken": {
|
||||||
|
"name": "verificationToken",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verificationToken_identifier_token_pk": {
|
||||||
|
"name": "verificationToken_identifier_token_pk",
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.wishlists": {
|
||||||
|
"name": "wishlists",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"owner_token": {
|
||||||
|
"name": "owner_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"public_token": {
|
||||||
|
"name": "public_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"wishlists_user_id_user_id_fk": {
|
||||||
|
"name": "wishlists_user_id_user_id_fk",
|
||||||
|
"tableFrom": "wishlists",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"wishlists_owner_token_unique": {
|
||||||
|
"name": "wishlists_owner_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"owner_token"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wishlists_public_token_unique": {
|
||||||
|
"name": "wishlists_public_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"public_token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
593
drizzle/meta/0002_snapshot.json
Normal file
593
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
{
|
||||||
|
"id": "381bf3dc-4cb0-4cd9-a269-fc90409921ee",
|
||||||
|
"prevId": "d5cb75d0-dd76-4feb-87ce-6b2e8b3aaae1",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"providerAccountId": {
|
||||||
|
"name": "providerAccountId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "numeric",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_type": {
|
||||||
|
"name": "token_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"session_state": {
|
||||||
|
"name": "session_state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_userId_user_id_fk": {
|
||||||
|
"name": "account_userId_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"account_provider_providerAccountId_pk": {
|
||||||
|
"name": "account_provider_providerAccountId_pk",
|
||||||
|
"columns": [
|
||||||
|
"provider",
|
||||||
|
"providerAccountId"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.items": {
|
||||||
|
"name": "items",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"wishlist_id": {
|
||||||
|
"name": "wishlist_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"name": "link",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"price": {
|
||||||
|
"name": "price",
|
||||||
|
"type": "numeric(10, 2)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"name": "currency",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'DKK'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'medium'"
|
||||||
|
},
|
||||||
|
"is_reserved": {
|
||||||
|
"name": "is_reserved",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"items_wishlist_id_wishlists_id_fk": {
|
||||||
|
"name": "items_wishlist_id_wishlists_id_fk",
|
||||||
|
"tableFrom": "items",
|
||||||
|
"tableTo": "wishlists",
|
||||||
|
"columnsFrom": [
|
||||||
|
"wishlist_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.reservations": {
|
||||||
|
"name": "reservations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reserver_name": {
|
||||||
|
"name": "reserver_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reservations_item_id_items_id_fk": {
|
||||||
|
"name": "reservations_item_id_items_id_fk",
|
||||||
|
"tableFrom": "reservations",
|
||||||
|
"tableTo": "items",
|
||||||
|
"columnsFrom": [
|
||||||
|
"item_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.saved_wishlists": {
|
||||||
|
"name": "saved_wishlists",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"wishlist_id": {
|
||||||
|
"name": "wishlist_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"nickname": {
|
||||||
|
"name": "nickname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"saved_wishlists_user_id_user_id_fk": {
|
||||||
|
"name": "saved_wishlists_user_id_user_id_fk",
|
||||||
|
"tableFrom": "saved_wishlists",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"saved_wishlists_wishlist_id_wishlists_id_fk": {
|
||||||
|
"name": "saved_wishlists_wishlist_id_wishlists_id_fk",
|
||||||
|
"tableFrom": "saved_wishlists",
|
||||||
|
"tableTo": "wishlists",
|
||||||
|
"columnsFrom": [
|
||||||
|
"wishlist_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"sessionToken": {
|
||||||
|
"name": "sessionToken",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"userId": {
|
||||||
|
"name": "userId",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_userId_user_id_fk": {
|
||||||
|
"name": "session_userId_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"emailVerified": {
|
||||||
|
"name": "emailVerified",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_username_unique": {
|
||||||
|
"name": "user_username_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verificationToken": {
|
||||||
|
"name": "verificationToken",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires": {
|
||||||
|
"name": "expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"verificationToken_identifier_token_pk": {
|
||||||
|
"name": "verificationToken_identifier_token_pk",
|
||||||
|
"columns": [
|
||||||
|
"identifier",
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.wishlists": {
|
||||||
|
"name": "wishlists",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"owner_token": {
|
||||||
|
"name": "owner_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"public_token": {
|
||||||
|
"name": "public_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"wishlists_user_id_user_id_fk": {
|
||||||
|
"name": "wishlists_user_id_user_id_fk",
|
||||||
|
"tableFrom": "wishlists",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"wishlists_owner_token_unique": {
|
||||||
|
"name": "wishlists_owner_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"owner_token"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"wishlists_public_token_unique": {
|
||||||
|
"name": "wishlists_public_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"public_token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
drizzle/meta/_journal.json
Normal file
27
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763822218153,
|
||||||
|
"tag": "0000_last_steve_rogers",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763987735422,
|
||||||
|
"tag": "0001_petite_silver_centurion",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1764006320677,
|
||||||
|
"tag": "0002_lowly_electro",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"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",
|
||||||
"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",
|
||||||
@@ -16,26 +17,37 @@
|
|||||||
"db:studio": "drizzle-kit studio"
|
"db:studio": "drizzle-kit studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@lucide/svelte": "^0.544.0",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.4.0",
|
||||||
"@sveltejs/kit": "^2.48.5",
|
"@sveltejs/kit": "^2.48.5",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"drizzle-kit": "^0.31.7",
|
"drizzle-kit": "^0.31.7",
|
||||||
|
"patch-package": "^8.0.1",
|
||||||
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"svelte": "^5.43.8",
|
"svelte": "^5.43.8",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.4",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.2.2"
|
"vite": "^7.2.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/core": "^0.34.3",
|
||||||
|
"@auth/drizzle-adapter": "^1.11.1",
|
||||||
|
"@auth/sveltekit": "^1.11.1",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@internationalized/date": "^3.10.0",
|
||||||
"@paralleldrive/cuid2": "^3.0.4",
|
"@paralleldrive/cuid2": "^3.0.4",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"bits-ui": "^2.14.4",
|
"bits-ui": "^2.14.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
|
"lucide-svelte": "^0.554.0",
|
||||||
"postgres": "^3.4.7",
|
"postgres": "^3.4.7",
|
||||||
|
"svelte-dnd-action": "^0.9.67",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwind-variants": "^3.1.1"
|
"tailwind-variants": "^3.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
patches/@auth+core+0.34.3.patch
Normal file
18
patches/@auth+core+0.34.3.patch
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
diff --git a/node_modules/@auth/core/lib/utils/env.js b/node_modules/@auth/core/lib/utils/env.js
|
||||||
|
index 1234567..abcdefg 100644
|
||||||
|
--- a/node_modules/@auth/core/lib/utils/env.js
|
||||||
|
+++ b/node_modules/@auth/core/lib/utils/env.js
|
||||||
|
@@ -44,7 +44,12 @@ export function setEnvDefaults(envObject, config) {
|
||||||
|
return finalProvider;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
-export function createActionURL(action, protocol, headers, envObject, basePath) {
|
||||||
|
+export function createActionURL(action, protocol, headers, envObject, configOrBasePath) {
|
||||||
|
+ // Fix: Extract basePath from config object if needed
|
||||||
|
+ const basePath = typeof configOrBasePath === 'object' && configOrBasePath !== null
|
||||||
|
+ ? configOrBasePath.basePath
|
||||||
|
+ : configOrBasePath;
|
||||||
|
+
|
||||||
|
let envUrl = envObject.AUTH_URL ?? envObject.NEXTAUTH_URL;
|
||||||
|
let url;
|
||||||
|
if (envUrl) {
|
||||||
129
src/app.css
Normal file
129
src/app.css
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
* {
|
||||||
|
transition:
|
||||||
|
background-color 1s ease,
|
||||||
|
background-image 1s ease,
|
||||||
|
color 1s ease,
|
||||||
|
border-color 1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--primary: oklch(0.208 0.042 265.755);
|
||||||
|
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--secondary: oklch(0.968 0.007 247.896);
|
||||||
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--muted: oklch(0.968 0.007 247.896);
|
||||||
|
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||||
|
--accent: oklch(0.968 0.007 247.896);
|
||||||
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.929 0.013 255.508);
|
||||||
|
--input: oklch(0.929 0.013 255.508);
|
||||||
|
--ring: oklch(0.704 0.04 256.788);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||||
|
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||||
|
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||||
|
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.129 0.042 264.695);
|
||||||
|
--foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--card: oklch(0.208 0.042 265.755);
|
||||||
|
--card-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--popover: oklch(0.208 0.042 265.755);
|
||||||
|
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--primary: oklch(0.929 0.013 255.508);
|
||||||
|
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||||
|
--secondary: oklch(0.279 0.041 260.031);
|
||||||
|
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--muted: oklch(0.279 0.041 260.031);
|
||||||
|
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||||
|
--accent: oklch(0.279 0.041 260.031);
|
||||||
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.551 0.027 264.364);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.208 0.042 265.755);
|
||||||
|
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||||
|
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app.d.ts
vendored
Normal file
14
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Session } from '@auth/core/types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
session: Session | null;
|
||||||
|
}
|
||||||
|
interface PageData {
|
||||||
|
session: Session | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
21
src/app.html
Normal file
21
src/app.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const theme = localStorage.getItem('theme') || 'system';
|
||||||
|
const isDark = theme === 'dark' ||
|
||||||
|
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
123
src/auth.ts
Normal file
123
src/auth.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||||
|
import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||||
|
import Credentials from '@auth/core/providers/credentials';
|
||||||
|
import Google from '@auth/core/providers/google';
|
||||||
|
import type { OAuthConfig } from '@auth/core/providers';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { users } from '$lib/server/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type { SvelteKitAuthConfig } from '@auth/sveltekit';
|
||||||
|
|
||||||
|
function Authentik(config: {
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
issuer: string;
|
||||||
|
}): OAuthConfig<any> {
|
||||||
|
return {
|
||||||
|
id: 'authentik',
|
||||||
|
name: 'Authentik',
|
||||||
|
type: 'oidc',
|
||||||
|
clientId: config.clientId,
|
||||||
|
clientSecret: config.clientSecret,
|
||||||
|
issuer: config.issuer,
|
||||||
|
authorization: {
|
||||||
|
params: {
|
||||||
|
scope: 'openid email profile'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub,
|
||||||
|
email: profile.email,
|
||||||
|
name: profile.name || profile.preferred_username,
|
||||||
|
image: profile.picture
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authConfig: SvelteKitAuthConfig = {
|
||||||
|
adapter: DrizzleAdapter(db),
|
||||||
|
session: {
|
||||||
|
strategy: 'jwt'
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
||||||
|
? [
|
||||||
|
Google({
|
||||||
|
clientId: env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: env.GOOGLE_CLIENT_SECRET
|
||||||
|
})
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER
|
||||||
|
? [
|
||||||
|
Authentik({
|
||||||
|
clientId: env.AUTHENTIK_CLIENT_ID,
|
||||||
|
clientSecret: env.AUTHENTIK_CLIENT_SECRET,
|
||||||
|
issuer: env.AUTHENTIK_ISSUER
|
||||||
|
})
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
Credentials({
|
||||||
|
id: 'credentials',
|
||||||
|
name: 'credentials',
|
||||||
|
credentials: {
|
||||||
|
username: { label: 'Username', type: 'text' },
|
||||||
|
password: { label: 'Password', type: 'password' }
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.username || !credentials?.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.username, credentials.username as string)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.password) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidPassword = await bcrypt.compare(
|
||||||
|
credentials.password as string,
|
||||||
|
user.password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidPassword) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email || undefined,
|
||||||
|
name: user.name,
|
||||||
|
image: user.image
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
pages: {
|
||||||
|
signIn: '/signin'
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
secret: env.AUTH_SECRET,
|
||||||
|
trustHost: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export const { handle, signIn, signOut } = SvelteKitAuth(authConfig);
|
||||||
1
src/hooks.server.ts
Normal file
1
src/hooks.server.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { handle } from './auth';
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
45
src/lib/components/dashboard/WishlistCard.svelte
Normal file
45
src/lib/components/dashboard/WishlistCard.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
itemCount,
|
||||||
|
color = null,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
itemCount: number;
|
||||||
|
color?: string | null;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const cardStyle = $derived(getCardStyle(color));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card style={cardStyle} class="h-full flex flex-col">
|
||||||
|
<CardHeader class="flex-shrink-0">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<CardTitle class="text-lg flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span class="truncate">{title}</span>
|
||||||
|
</CardTitle>
|
||||||
|
<span class="text-sm text-muted-foreground flex-shrink-0">
|
||||||
|
{itemCount} item{itemCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if description}
|
||||||
|
<CardDescription class="line-clamp-3 whitespace-pre-line">{description}</CardDescription>
|
||||||
|
{/if}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-2 flex-1 flex flex-col justify-end">
|
||||||
|
{#if children}
|
||||||
|
<div>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
79
src/lib/components/dashboard/WishlistGrid.svelte
Normal file
79
src/lib/components/dashboard/WishlistGrid.svelte
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import EmptyState from '$lib/components/layout/EmptyState.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
import { flip } from 'svelte/animate';
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
emptyMessage,
|
||||||
|
emptyDescription,
|
||||||
|
emptyActionLabel,
|
||||||
|
emptyActionHref,
|
||||||
|
headerAction,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: any[];
|
||||||
|
emptyMessage: string;
|
||||||
|
emptyDescription?: string;
|
||||||
|
emptyActionLabel?: string;
|
||||||
|
emptyActionHref?: string;
|
||||||
|
headerAction?: Snippet;
|
||||||
|
children: Snippet<[any]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let scrollContainer: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function handleWheel(event: WheelEvent) {
|
||||||
|
if (!scrollContainer) return;
|
||||||
|
|
||||||
|
// Check if we have horizontal overflow
|
||||||
|
const hasHorizontalScroll = scrollContainer.scrollWidth > scrollContainer.clientWidth;
|
||||||
|
|
||||||
|
if (hasHorizontalScroll && event.deltaY !== 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
scrollContainer.scrollLeft += event.deltaY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
<CardDescription>{description}</CardDescription>
|
||||||
|
</div>
|
||||||
|
{#if headerAction}
|
||||||
|
{@render headerAction()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{#if items && items.length > 0}
|
||||||
|
<div
|
||||||
|
bind:this={scrollContainer}
|
||||||
|
onwheel={handleWheel}
|
||||||
|
class="flex overflow-x-auto gap-4 pb-4 -mx-6 px-6"
|
||||||
|
>
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<div class="flex-shrink-0 w-80" animate:flip={{ duration: 300 }}>
|
||||||
|
{@render children(item)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
message={emptyMessage}
|
||||||
|
description={emptyDescription}
|
||||||
|
actionLabel={emptyActionLabel}
|
||||||
|
actionHref={emptyActionHref}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
18
src/lib/components/layout/DashboardHeader.svelte
Normal file
18
src/lib/components/layout/DashboardHeader.svelte
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import { signOut } from '@auth/sveltekit/client';
|
||||||
|
|
||||||
|
let { userName, userEmail }: { userName?: string | null; userEmail?: string | null } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold">Dashboard</h1>
|
||||||
|
<p class="text-muted-foreground">Welcome back, {userName || userEmail}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
<Button variant="outline" onclick={() => signOut({ callbackUrl: '/' })}>Sign Out</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
45
src/lib/components/layout/EmptyState.svelte
Normal file
45
src/lib/components/layout/EmptyState.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
message,
|
||||||
|
description,
|
||||||
|
actionLabel,
|
||||||
|
actionHref,
|
||||||
|
onclick,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
description?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
actionHref?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
children?: Snippet;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="text-center py-8 text-muted-foreground">
|
||||||
|
<p class="text-base">{message}</p>
|
||||||
|
{#if description}
|
||||||
|
<p class="text-sm mt-2">{description}</p>
|
||||||
|
{/if}
|
||||||
|
{#if children}
|
||||||
|
<div class="mt-4">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{:else if actionLabel}
|
||||||
|
<Button
|
||||||
|
class="mt-4"
|
||||||
|
onclick={() => {
|
||||||
|
if (onclick) {
|
||||||
|
onclick();
|
||||||
|
} else if (actionHref) {
|
||||||
|
window.location.href = actionHref;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
34
src/lib/components/layout/Navigation.svelte
Normal file
34
src/lib/components/layout/Navigation.svelte
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import { LanguageToggle } from '$lib/components/ui/language-toggle';
|
||||||
|
import { LayoutDashboard } from 'lucide-svelte';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
isAuthenticated = false,
|
||||||
|
showDashboardLink = false
|
||||||
|
}: {
|
||||||
|
isAuthenticated?: boolean;
|
||||||
|
showDashboardLink?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const t = $derived(languageStore.t);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="flex items-center gap-1 sm:gap-2 mb-6 w-full">
|
||||||
|
{#if isAuthenticated}
|
||||||
|
<Button variant="outline" size="sm" onclick={() => (window.location.href = '/dashboard')} class="px-2 sm:px-3">
|
||||||
|
<LayoutDashboard class="w-4 h-4" />
|
||||||
|
<span class="hidden sm:inline sm:ml-2">{t.nav.dashboard}</span>
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button variant="outline" 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 />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
11
src/lib/components/layout/PageContainer.svelte
Normal file
11
src/lib/components/layout/PageContainer.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { children, maxWidth = '6xl' }: { children: Snippet; maxWidth?: string } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen p-4 md:p-8">
|
||||||
|
<div class="max-w-{maxWidth} mx-auto space-y-6">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
63
src/lib/components/ui/ColorPicker.svelte
Normal file
63
src/lib/components/ui/ColorPicker.svelte
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { X, Pencil } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
color = $bindable(null),
|
||||||
|
size = 'md',
|
||||||
|
onchange
|
||||||
|
}: {
|
||||||
|
color: string | null;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
onchange?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-8 h-8',
|
||||||
|
md: 'w-10 h-10',
|
||||||
|
lg: 'w-12 h-12'
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconSizeClasses = {
|
||||||
|
sm: 'w-3 h-3',
|
||||||
|
md: 'w-4 h-4',
|
||||||
|
lg: 'w-5 h-5'
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonSize = sizeClasses[size];
|
||||||
|
const iconSize = iconSizeClasses[size];
|
||||||
|
|
||||||
|
function handleColorChange(e: Event) {
|
||||||
|
color = (e.target as HTMLInputElement).value;
|
||||||
|
onchange?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearColor() {
|
||||||
|
color = null;
|
||||||
|
onchange?.();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if color}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearColor}
|
||||||
|
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||||
|
aria-label="Clear color"
|
||||||
|
>
|
||||||
|
<X class={iconSize} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<label
|
||||||
|
class="{buttonSize} flex items-center justify-center rounded-full border border-input hover:opacity-90 transition-opacity cursor-pointer relative overflow-hidden"
|
||||||
|
style={color ? `background-color: ${color};` : ''}
|
||||||
|
>
|
||||||
|
<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));' : ''} />
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={color || '#ffffff'}
|
||||||
|
oninput={handleColorChange}
|
||||||
|
class="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
58
src/lib/components/ui/language-toggle/LanguageToggle.svelte
Normal file
58
src/lib/components/ui/language-toggle/LanguageToggle.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
import { languages } from '$lib/i18n/translations';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Languages } from 'lucide-svelte';
|
||||||
|
|
||||||
|
let showMenu = $state(false);
|
||||||
|
|
||||||
|
function toggleMenu() {
|
||||||
|
showMenu = !showMenu;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLanguage(code: 'en' | 'da') {
|
||||||
|
languageStore.setLanguage(code);
|
||||||
|
showMenu = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (!target.closest('.language-toggle-menu')) {
|
||||||
|
showMenu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showMenu) {
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('click', handleClickOutside);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative language-toggle-menu">
|
||||||
|
<Button variant="outline" size="icon" onclick={toggleMenu} aria-label="Toggle language">
|
||||||
|
<Languages class="h-[1.2rem] w-[1.2rem]" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if showMenu}
|
||||||
|
<div
|
||||||
|
class="absolute right-0 mt-2 w-40 rounded-md border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 shadow-lg z-50"
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
{#each languages as lang}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-4 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-900 transition-colors"
|
||||||
|
class:font-bold={languageStore.current === lang.code}
|
||||||
|
class:bg-slate-100={languageStore.current === lang.code}
|
||||||
|
class:dark:bg-slate-900={languageStore.current === lang.code}
|
||||||
|
onclick={() => setLanguage(lang.code)}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
2
src/lib/components/ui/language-toggle/index.ts
Normal file
2
src/lib/components/ui/language-toggle/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import LanguageToggle from './LanguageToggle.svelte';
|
||||||
|
export { LanguageToggle };
|
||||||
22
src/lib/components/ui/theme-toggle/ThemeToggle.svelte
Normal file
22
src/lib/components/ui/theme-toggle/ThemeToggle.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { themeStore } from '$lib/stores/theme.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Sun, Moon, Monitor } from 'lucide-svelte';
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
themeStore.toggle();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button onclick={toggle} variant="ghost" size="icon" class="rounded-full">
|
||||||
|
{#if themeStore.current === 'light'}
|
||||||
|
<Sun size={20} />
|
||||||
|
<span class="sr-only">Light mode (click for dark)</span>
|
||||||
|
{:else if themeStore.current === 'dark'}
|
||||||
|
<Moon size={20} />
|
||||||
|
<span class="sr-only">Dark mode (click for system)</span>
|
||||||
|
{:else}
|
||||||
|
<Monitor size={20} />
|
||||||
|
<span class="sr-only">System mode (click for light)</span>
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
1
src/lib/components/ui/theme-toggle/index.ts
Normal file
1
src/lib/components/ui/theme-toggle/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as ThemeToggle } from './ThemeToggle.svelte';
|
||||||
140
src/lib/components/wishlist/AddItemForm.svelte
Normal file
140
src/lib/components/wishlist/AddItemForm.svelte
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onSuccess }: Props = $props();
|
||||||
|
|
||||||
|
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>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Add New Item</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<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">Item Name *</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">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">Link (URL)</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">Image URL</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">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">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">Card Color (optional)</Label>
|
||||||
|
<ColorPicker bind:color={color} />
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="color" value={color || ''} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" class="w-full md:w-auto">Add Item</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
176
src/lib/components/wishlist/EditItemForm.svelte
Normal file
176
src/lib/components/wishlist/EditItemForm.svelte
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Item;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onColorChange?: (itemId: string, color: string) => void;
|
||||||
|
currentPosition?: number;
|
||||||
|
totalItems?: number;
|
||||||
|
onPositionChange?: (newPosition: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { item, onSuccess, onCancel, onColorChange, currentPosition = 1, totalItems = 1, onPositionChange }: Props = $props();
|
||||||
|
|
||||||
|
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>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Edit Item</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<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">Item Name *</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">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">Link (URL)</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">Image URL</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">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">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">Card Color (optional)</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">Position in List</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">Save Changes</Button>
|
||||||
|
{#if onCancel}
|
||||||
|
<Button type="button" variant="outline" class="flex-1 md:flex-none" onclick={onCancel}>Cancel</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
72
src/lib/components/wishlist/EditableItemsList.svelte
Normal file
72
src/lib/components/wishlist/EditableItemsList.svelte
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
|
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
|
||||||
|
import EmptyState from "$lib/components/layout/EmptyState.svelte";
|
||||||
|
import type { Item } from "$lib/server/schema";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import { flip } from "svelte/animate";
|
||||||
|
|
||||||
|
let {
|
||||||
|
items = $bindable([]),
|
||||||
|
rearranging,
|
||||||
|
onStartEditing,
|
||||||
|
onReorder
|
||||||
|
}: {
|
||||||
|
items: Item[];
|
||||||
|
rearranging: boolean;
|
||||||
|
onStartEditing: (item: Item) => void;
|
||||||
|
onReorder: (items: Item[]) => Promise<void>;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#if items && items.length > 0}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each items as item (item.id)}
|
||||||
|
<div animate:flip={{ duration: 300 }}>
|
||||||
|
<WishlistItem {item} showDragHandle={false}>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => onStartEditing(item)}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
{#if rearranging}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteItem"
|
||||||
|
use:enhance
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="itemId"
|
||||||
|
value={item.id}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</WishlistItem>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-12">
|
||||||
|
<EmptyState
|
||||||
|
message="No items yet. Click 'Add Item' to get started!"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
33
src/lib/components/wishlist/ImageSelector.svelte
Normal file
33
src/lib/components/wishlist/ImageSelector.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
|
||||||
|
let {
|
||||||
|
images,
|
||||||
|
selectedImage = $bindable(''),
|
||||||
|
isLoading = false
|
||||||
|
}: {
|
||||||
|
images: string[];
|
||||||
|
selectedImage?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<p class="text-sm text-muted-foreground">Loading images...</p>
|
||||||
|
{:else if images.length > 0}
|
||||||
|
<div class="mt-2">
|
||||||
|
<Label class="text-sm">Or select from scraped images:</Label>
|
||||||
|
<div class="grid grid-cols-3 md:grid-cols-5 gap-2 mt-2">
|
||||||
|
{#each images as imgUrl}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (selectedImage = imgUrl)}
|
||||||
|
class="relative aspect-square rounded-md overflow-hidden border-2 hover:border-primary transition-colors"
|
||||||
|
class:border-primary={selectedImage === imgUrl}
|
||||||
|
>
|
||||||
|
<img src={imgUrl} alt="" class="w-full h-full object-cover" />
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
70
src/lib/components/wishlist/ReservationButton.svelte
Normal file
70
src/lib/components/wishlist/ReservationButton.svelte
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
itemId: string;
|
||||||
|
isReserved: boolean;
|
||||||
|
reserverName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { itemId, isReserved, reserverName }: Props = $props();
|
||||||
|
|
||||||
|
let showReserveForm = $state(false);
|
||||||
|
let name = $state('');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isReserved}
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<div class="text-sm text-green-600 font-medium">
|
||||||
|
✓ Reserved
|
||||||
|
{#if reserverName}
|
||||||
|
by {reserverName}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/unreserve" use:enhance>
|
||||||
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Cancel Reservation
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{:else if showReserveForm}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/reserve"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
showReserveForm = false;
|
||||||
|
name = '';
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex flex-col gap-2 w-full md:w-auto"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="itemId" value={itemId} />
|
||||||
|
<Input
|
||||||
|
name="reserverName"
|
||||||
|
placeholder="Your name (optional)"
|
||||||
|
bind:value={name}
|
||||||
|
class="w-full md:w-48"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="submit" size="sm" class="flex-1">Confirm</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (showReserveForm = false)}
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<Button onclick={() => (showReserveForm = true)} size="sm" class="w-full md:w-auto">
|
||||||
|
Reserve This
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
58
src/lib/components/wishlist/ShareLinks.svelte
Normal file
58
src/lib/components/wishlist/ShareLinks.svelte
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<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 { Card, CardContent } from '$lib/components/ui/card';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
publicUrl: string;
|
||||||
|
ownerUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { publicUrl, ownerUrl }: Props = $props();
|
||||||
|
|
||||||
|
let copiedPublic = $state(false);
|
||||||
|
let copiedOwner = $state(false);
|
||||||
|
|
||||||
|
const publicLink = $derived(
|
||||||
|
typeof window !== 'undefined' ? `${window.location.origin}${publicUrl}` : ''
|
||||||
|
);
|
||||||
|
const ownerLink = $derived(ownerUrl && typeof window !== 'undefined' ? `${window.location.origin}${ownerUrl}` : '');
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string, type: 'public' | 'owner') {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
if (type === 'public') {
|
||||||
|
copiedPublic = true;
|
||||||
|
setTimeout(() => (copiedPublic = false), 2000);
|
||||||
|
} else {
|
||||||
|
copiedOwner = true;
|
||||||
|
setTimeout(() => (copiedOwner = false), 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent class="space-y-4 pt-6">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Share with friends (view only)</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input readonly value={publicLink} class="font-mono text-sm" />
|
||||||
|
<Button variant="outline" onclick={() => copyToClipboard(publicLink, 'public')}>
|
||||||
|
{copiedPublic ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if ownerLink}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>Your edit link (keep this private!)</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input readonly value={ownerLink} class="font-mono text-sm" />
|
||||||
|
<Button variant="outline" onclick={() => copyToClipboard(ownerLink, 'owner')}>
|
||||||
|
{copiedOwner ? 'Copied!' : 'Copy'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
33
src/lib/components/wishlist/WishlistActionButtons.svelte
Normal file
33
src/lib/components/wishlist/WishlistActionButtons.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Lock, LockOpen } from "lucide-svelte";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
|
let {
|
||||||
|
rearranging = $bindable(false),
|
||||||
|
onToggleAddForm
|
||||||
|
}: {
|
||||||
|
rearranging: boolean;
|
||||||
|
onToggleAddForm: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
|
||||||
|
function toggleAddForm() {
|
||||||
|
showAddForm = !showAddForm;
|
||||||
|
onToggleAddForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRearranging() {
|
||||||
|
rearranging = !rearranging;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row gap-4">
|
||||||
|
<Button
|
||||||
|
onclick={toggleAddForm}
|
||||||
|
class="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
{showAddForm ? "Cancel" : "+ Add Item"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
191
src/lib/components/wishlist/WishlistHeader.svelte
Normal file
191
src/lib/components/wishlist/WishlistHeader.svelte
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import { Textarea } from "$lib/components/ui/textarea";
|
||||||
|
import { Pencil, Check, X } from "lucide-svelte";
|
||||||
|
import ColorPicker from "$lib/components/ui/ColorPicker.svelte";
|
||||||
|
import type { Wishlist } from "$lib/server/schema";
|
||||||
|
|
||||||
|
let {
|
||||||
|
wishlist,
|
||||||
|
onTitleUpdate,
|
||||||
|
onDescriptionUpdate,
|
||||||
|
onColorUpdate,
|
||||||
|
onEndDateUpdate
|
||||||
|
}: {
|
||||||
|
wishlist: Wishlist;
|
||||||
|
onTitleUpdate: (title: string) => Promise<boolean>;
|
||||||
|
onDescriptionUpdate: (description: string | null) => Promise<boolean>;
|
||||||
|
onColorUpdate: (color: string | null) => void;
|
||||||
|
onEndDateUpdate: (endDate: string | null) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let editingTitle = $state(false);
|
||||||
|
let editingDescription = $state(false);
|
||||||
|
let wishlistTitle = $state(wishlist.title);
|
||||||
|
let wishlistDescription = $state(wishlist.description || "");
|
||||||
|
let wishlistColor = $state<string | null>(wishlist.color);
|
||||||
|
let wishlistEndDate = $state<string | null>(
|
||||||
|
wishlist.endDate
|
||||||
|
? new Date(wishlist.endDate).toISOString().split("T")[0]
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function saveTitle() {
|
||||||
|
if (!wishlistTitle.trim()) {
|
||||||
|
wishlistTitle = wishlist.title;
|
||||||
|
editingTitle = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await onTitleUpdate(wishlistTitle.trim());
|
||||||
|
if (success) {
|
||||||
|
editingTitle = false;
|
||||||
|
} else {
|
||||||
|
wishlistTitle = wishlist.title;
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDescription() {
|
||||||
|
const success = await onDescriptionUpdate(wishlistDescription.trim() || null);
|
||||||
|
if (success) {
|
||||||
|
editingDescription = false;
|
||||||
|
} else {
|
||||||
|
wishlistDescription = wishlist.description || "";
|
||||||
|
editingDescription = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEndDateChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement;
|
||||||
|
wishlistEndDate = input.value || null;
|
||||||
|
onEndDateUpdate(wishlistEndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearEndDate() {
|
||||||
|
wishlistEndDate = null;
|
||||||
|
onEndDateUpdate(null);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Title Header -->
|
||||||
|
<div class="flex items-center justify-between gap-4 mb-6">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
{#if editingTitle}
|
||||||
|
<Input
|
||||||
|
bind:value={wishlistTitle}
|
||||||
|
class="text-3xl font-bold h-auto py-0 leading-[2.25rem]"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
saveTitle();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
wishlistTitle = wishlist.title;
|
||||||
|
editingTitle = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<h1 class="text-3xl font-bold leading-[2.25rem]">{wishlistTitle}</h1>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
if (editingTitle) {
|
||||||
|
saveTitle();
|
||||||
|
} else {
|
||||||
|
editingTitle = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||||
|
aria-label={editingTitle ? "Save title" : "Edit title"}
|
||||||
|
>
|
||||||
|
{#if editingTitle}
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
{:else}
|
||||||
|
<Pencil class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<ColorPicker
|
||||||
|
bind:color={wishlistColor}
|
||||||
|
onchange={() => onColorUpdate(wishlistColor)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Card -->
|
||||||
|
<Card>
|
||||||
|
<CardContent class="pt-6 space-y-4">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<Label for="wishlist-description">Description (optional)</Label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
if (editingDescription) {
|
||||||
|
saveDescription();
|
||||||
|
} else {
|
||||||
|
editingDescription = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||||
|
aria-label={editingDescription ? "Save description" : "Edit description"}
|
||||||
|
>
|
||||||
|
{#if editingDescription}
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
{:else}
|
||||||
|
<Pencil class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if editingDescription}
|
||||||
|
<Textarea
|
||||||
|
id="wishlist-description"
|
||||||
|
bind:value={wishlistDescription}
|
||||||
|
class="w-full"
|
||||||
|
rows={3}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
wishlistDescription = wishlist.description || "";
|
||||||
|
editingDescription = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full py-2 px-3 rounded-md border border-input bg-transparent text-sm min-h-[80px]">
|
||||||
|
{wishlistDescription || "No description"}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- End Date -->
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4">
|
||||||
|
<Label for="wishlist-end-date">End Date (optional)</Label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if wishlistEndDate}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={clearEndDate}
|
||||||
|
class="flex-shrink-0 w-8 h-8 flex items-center justify-center rounded-full border border-input hover:bg-accent transition-colors"
|
||||||
|
aria-label="Clear end date"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<Input
|
||||||
|
id="wishlist-end-date"
|
||||||
|
type="date"
|
||||||
|
value={wishlistEndDate || ""}
|
||||||
|
onchange={handleEndDateChange}
|
||||||
|
class="w-full sm:w-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
116
src/lib/components/wishlist/WishlistItem.svelte
Normal file
116
src/lib/components/wishlist/WishlistItem.svelte
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Card, CardContent } from "$lib/components/ui/card";
|
||||||
|
import type { Item } from "$lib/server/schema";
|
||||||
|
import { GripVertical, Link } from "lucide-svelte";
|
||||||
|
import { getCardStyle } from '$lib/utils/colors';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
item: Item;
|
||||||
|
showImage?: boolean;
|
||||||
|
children?: any;
|
||||||
|
showDragHandle?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
showImage = true,
|
||||||
|
children,
|
||||||
|
showDragHandle = false,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
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));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card style={cardStyle}>
|
||||||
|
<CardContent class="p-6">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
{#if showDragHandle}
|
||||||
|
<div
|
||||||
|
class="cursor-grab active:cursor-grabbing hover:bg-accent rounded-md transition-colors self-center shrink-0 p-2 touch-none"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
style="touch-action: none;"
|
||||||
|
>
|
||||||
|
<GripVertical class="w-6 h-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col md:flex-row gap-4 flex-1">
|
||||||
|
{#if showImage && item.imageUrl}
|
||||||
|
<img
|
||||||
|
src={item.imageUrl}
|
||||||
|
alt={item.title}
|
||||||
|
class="w-full md:w-32 h-32 object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex-1 items-center">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between flex-wrap"
|
||||||
|
>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-lg">{item.title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if item.description}
|
||||||
|
<p class="text-muted-foreground">{item.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-wrap text-sm">
|
||||||
|
{#if item.price}
|
||||||
|
<span class="font-medium"
|
||||||
|
>{formatPrice(item.price, item.currency)}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if item.link}
|
||||||
|
<a
|
||||||
|
href={item.link}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<div class="flex flex-row gap-1 items-center">
|
||||||
|
<p class="text-muted-foreground">View Product</p>
|
||||||
|
<Link
|
||||||
|
class="pt-1 w-5 h-5 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
93
src/lib/i18n/translations/da.ts
Normal file
93
src/lib/i18n/translations/da.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Translation } from './en';
|
||||||
|
|
||||||
|
// Danish translations - ADD YOUR TRANSLATIONS HERE
|
||||||
|
export const da: Translation = {
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
dashboard: 'Dashboard' // TODO: Add Danish translation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard: {
|
||||||
|
myWishlists: 'My Wishlists', // TODO: Add Danish translation
|
||||||
|
myWishlistsDescription: 'Wishlists you own and manage', // TODO: Add Danish translation
|
||||||
|
savedWishlists: 'Saved Wishlists', // TODO: Add Danish translation
|
||||||
|
savedWishlistsDescription: "Wishlists you're following", // TODO: Add Danish translation
|
||||||
|
createNew: '+ Create New', // TODO: Add Danish translation
|
||||||
|
manage: 'Manage', // TODO: Add Danish translation
|
||||||
|
copyLink: 'Copy Link', // TODO: Add Danish translation
|
||||||
|
viewWishlist: 'View Wishlist', // TODO: Add Danish translation
|
||||||
|
unsave: 'Unsave', // TODO: Add Danish translation
|
||||||
|
emptyWishlists: "You haven't created any wishlists yet.", // TODO: Add Danish translation
|
||||||
|
emptyWishlistsAction: 'Create Your First Wishlist', // TODO: Add Danish translation
|
||||||
|
emptySavedWishlists: "You haven't saved any wishlists yet.", // TODO: Add Danish translation
|
||||||
|
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.", // TODO: Add Danish translation
|
||||||
|
by: 'by', // TODO: Add Danish translation
|
||||||
|
ends: 'Ends' // TODO: Add Danish translation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wishlist
|
||||||
|
wishlist: {
|
||||||
|
title: 'Wishlist', // TODO: Add Danish translation
|
||||||
|
addItem: 'Add Item', // TODO: Add Danish translation
|
||||||
|
editItem: 'Edit Item', // TODO: Add Danish translation
|
||||||
|
deleteItem: 'Delete Item', // TODO: Add Danish translation
|
||||||
|
reserve: 'Reserve', // TODO: Add Danish translation
|
||||||
|
unreserve: 'Unreserve', // TODO: Add Danish translation
|
||||||
|
reserved: 'Reserved', // TODO: Add Danish translation
|
||||||
|
save: 'Save', // TODO: Add Danish translation
|
||||||
|
saveWishlist: 'Save Wishlist', // TODO: Add Danish translation
|
||||||
|
share: 'Share', // TODO: Add Danish translation
|
||||||
|
edit: 'Edit', // TODO: Add Danish translation
|
||||||
|
back: 'Back', // TODO: Add Danish translation
|
||||||
|
noItems: 'No items yet', // TODO: Add Danish translation
|
||||||
|
addFirstItem: 'Add your first item' // TODO: Add Danish translation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
form: {
|
||||||
|
title: 'Title', // TODO: Add Danish translation
|
||||||
|
description: 'Description', // TODO: Add Danish translation
|
||||||
|
price: 'Price', // TODO: Add Danish translation
|
||||||
|
url: 'URL', // TODO: Add Danish translation
|
||||||
|
image: 'Image', // TODO: Add Danish translation
|
||||||
|
submit: 'Submit', // TODO: Add Danish translation
|
||||||
|
cancel: 'Cancel', // TODO: Add Danish translation
|
||||||
|
save: 'Save', // TODO: Add Danish translation
|
||||||
|
delete: 'Delete', // TODO: Add Danish translation
|
||||||
|
email: 'Email', // TODO: Add Danish translation
|
||||||
|
password: 'Password', // TODO: Add Danish translation
|
||||||
|
name: 'Name', // TODO: Add Danish translation
|
||||||
|
username: 'Username' // TODO: Add Danish translation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
auth: {
|
||||||
|
signIn: 'Sign In', // TODO: Add Danish translation
|
||||||
|
signUp: 'Sign Up', // TODO: Add Danish translation
|
||||||
|
signOut: 'Sign Out', // TODO: Add Danish translation
|
||||||
|
welcome: 'Welcome', // TODO: Add Danish translation
|
||||||
|
createAccount: 'Create Account', // TODO: Add Danish translation
|
||||||
|
alreadyHaveAccount: 'Already have an account?', // TODO: Add Danish translation
|
||||||
|
dontHaveAccount: "Don't have an account?" // TODO: Add Danish translation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common
|
||||||
|
common: {
|
||||||
|
loading: 'Loading...', // TODO: Add Danish translation
|
||||||
|
error: 'Error', // TODO: Add Danish translation
|
||||||
|
success: 'Success', // TODO: Add Danish translation
|
||||||
|
confirm: 'Confirm', // TODO: Add Danish translation
|
||||||
|
close: 'Close', // TODO: Add Danish translation
|
||||||
|
or: 'or', // TODO: Add Danish translation
|
||||||
|
and: 'and' // TODO: Add Danish translation
|
||||||
|
},
|
||||||
|
|
||||||
|
// Date formatting
|
||||||
|
date: {
|
||||||
|
format: {
|
||||||
|
short: 'da-DK',
|
||||||
|
long: 'da-DK'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
168
src/lib/i18n/translations/en.ts
Normal file
168
src/lib/i18n/translations/en.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
export const en = {
|
||||||
|
// Navigation
|
||||||
|
nav: {
|
||||||
|
dashboard: 'Dashboard'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
dashboard: {
|
||||||
|
myWishlists: 'My Wishlists',
|
||||||
|
myWishlistsDescription: 'Wishlists you own and manage',
|
||||||
|
savedWishlists: 'Saved Wishlists',
|
||||||
|
savedWishlistsDescription: "Wishlists you're following",
|
||||||
|
createNew: '+ Create New',
|
||||||
|
manage: 'Manage',
|
||||||
|
copyLink: 'Copy Link',
|
||||||
|
viewWishlist: 'View Wishlist',
|
||||||
|
unsave: 'Unsave',
|
||||||
|
emptyWishlists: "You haven't created any wishlists yet.",
|
||||||
|
emptyWishlistsAction: 'Create Your First Wishlist',
|
||||||
|
emptySavedWishlists: "You haven't saved any wishlists yet.",
|
||||||
|
emptySavedWishlistsDescription: "When viewing someone's wishlist, you can save it to easily find it later.",
|
||||||
|
by: 'by',
|
||||||
|
ends: 'Ends'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Wishlist
|
||||||
|
wishlist: {
|
||||||
|
title: 'Wishlist',
|
||||||
|
addItem: 'Add Item',
|
||||||
|
editItem: 'Edit Item',
|
||||||
|
deleteItem: 'Delete Item',
|
||||||
|
reserve: 'Reserve',
|
||||||
|
unreserve: 'Unreserve',
|
||||||
|
reserved: 'Reserved',
|
||||||
|
save: 'Save',
|
||||||
|
saveWishlist: 'Save Wishlist',
|
||||||
|
share: 'Share',
|
||||||
|
edit: 'Edit',
|
||||||
|
back: 'Back',
|
||||||
|
noItems: 'No items yet',
|
||||||
|
addFirstItem: 'Add your first item'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Forms
|
||||||
|
form: {
|
||||||
|
title: 'Title',
|
||||||
|
description: 'Description',
|
||||||
|
price: 'Price',
|
||||||
|
url: 'URL',
|
||||||
|
image: 'Image',
|
||||||
|
submit: 'Submit',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
save: 'Save',
|
||||||
|
delete: 'Delete',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
name: 'Name',
|
||||||
|
username: 'Username'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
auth: {
|
||||||
|
signIn: 'Sign In',
|
||||||
|
signUp: 'Sign Up',
|
||||||
|
signOut: 'Sign Out',
|
||||||
|
welcome: 'Welcome',
|
||||||
|
createAccount: 'Create Account',
|
||||||
|
alreadyHaveAccount: 'Already have an account?',
|
||||||
|
dontHaveAccount: "Don't have an account?"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Common
|
||||||
|
common: {
|
||||||
|
loading: 'Loading...',
|
||||||
|
error: 'Error',
|
||||||
|
success: 'Success',
|
||||||
|
confirm: 'Confirm',
|
||||||
|
close: 'Close',
|
||||||
|
or: 'or',
|
||||||
|
and: 'and'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Date formatting
|
||||||
|
date: {
|
||||||
|
format: {
|
||||||
|
short: 'en-US',
|
||||||
|
long: 'en-US'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Translation = {
|
||||||
|
nav: {
|
||||||
|
dashboard: string;
|
||||||
|
};
|
||||||
|
dashboard: {
|
||||||
|
myWishlists: string;
|
||||||
|
myWishlistsDescription: string;
|
||||||
|
savedWishlists: string;
|
||||||
|
savedWishlistsDescription: string;
|
||||||
|
createNew: string;
|
||||||
|
manage: string;
|
||||||
|
copyLink: string;
|
||||||
|
viewWishlist: string;
|
||||||
|
unsave: string;
|
||||||
|
emptyWishlists: string;
|
||||||
|
emptyWishlistsAction: string;
|
||||||
|
emptySavedWishlists: string;
|
||||||
|
emptySavedWishlistsDescription: string;
|
||||||
|
by: string;
|
||||||
|
ends: string;
|
||||||
|
};
|
||||||
|
wishlist: {
|
||||||
|
title: string;
|
||||||
|
addItem: string;
|
||||||
|
editItem: string;
|
||||||
|
deleteItem: string;
|
||||||
|
reserve: string;
|
||||||
|
unreserve: string;
|
||||||
|
reserved: string;
|
||||||
|
save: string;
|
||||||
|
saveWishlist: string;
|
||||||
|
share: string;
|
||||||
|
edit: string;
|
||||||
|
back: string;
|
||||||
|
noItems: string;
|
||||||
|
addFirstItem: string;
|
||||||
|
};
|
||||||
|
form: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
url: string;
|
||||||
|
image: string;
|
||||||
|
submit: string;
|
||||||
|
cancel: string;
|
||||||
|
save: string;
|
||||||
|
delete: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
auth: {
|
||||||
|
signIn: string;
|
||||||
|
signUp: string;
|
||||||
|
signOut: string;
|
||||||
|
welcome: string;
|
||||||
|
createAccount: string;
|
||||||
|
alreadyHaveAccount: string;
|
||||||
|
dontHaveAccount: string;
|
||||||
|
};
|
||||||
|
common: {
|
||||||
|
loading: string;
|
||||||
|
error: string;
|
||||||
|
success: string;
|
||||||
|
confirm: string;
|
||||||
|
close: string;
|
||||||
|
or: string;
|
||||||
|
and: string;
|
||||||
|
};
|
||||||
|
date: {
|
||||||
|
format: {
|
||||||
|
short: string;
|
||||||
|
long: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
15
src/lib/i18n/translations/index.ts
Normal file
15
src/lib/i18n/translations/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { en } from './en';
|
||||||
|
import { da } from './da';
|
||||||
|
import type { Translation } from './en';
|
||||||
|
|
||||||
|
export const translations: Record<string, Translation> = {
|
||||||
|
en,
|
||||||
|
da
|
||||||
|
};
|
||||||
|
|
||||||
|
export const languages = [
|
||||||
|
{ code: 'en', name: 'English' },
|
||||||
|
{ code: 'da', name: 'Dansk' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type LanguageCode = 'en' | 'da';
|
||||||
1
src/lib/index.ts
Normal file
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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 { DATABASE_URL } from '$env/static/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import * as schema from './schema';
|
import * as schema from './schema';
|
||||||
|
|
||||||
const client = postgres(DATABASE_URL);
|
const client = postgres(env.DATABASE_URL!);
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
166
src/lib/server/schema.ts
Normal file
166
src/lib/server/schema.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { pgTable, text, timestamp, numeric, boolean, primaryKey } from 'drizzle-orm/pg-core';
|
||||||
|
import { relations } from 'drizzle-orm';
|
||||||
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
import type { AdapterAccountType } from '@auth/core/adapters';
|
||||||
|
|
||||||
|
export const users = pgTable('user', {
|
||||||
|
id: text('id')
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => createId()),
|
||||||
|
name: text('name'),
|
||||||
|
email: text('email').unique(),
|
||||||
|
emailVerified: timestamp('emailVerified', { mode: 'date' }),
|
||||||
|
image: text('image'),
|
||||||
|
password: text('password'),
|
||||||
|
username: text('username').unique()
|
||||||
|
});
|
||||||
|
|
||||||
|
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'),
|
||||||
|
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' }),
|
||||||
|
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]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
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' }),
|
||||||
|
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)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
export type Wishlist = typeof wishlists.$inferSelect;
|
||||||
|
export type NewWishlist = typeof wishlists.$inferInsert;
|
||||||
|
export type Item = typeof items.$inferSelect;
|
||||||
|
export type NewItem = typeof items.$inferInsert;
|
||||||
|
export type Reservation = typeof reservations.$inferSelect;
|
||||||
|
export type NewReservation = typeof reservations.$inferInsert;
|
||||||
|
export type SavedWishlist = typeof savedWishlists.$inferSelect;
|
||||||
|
export type NewSavedWishlist = typeof savedWishlists.$inferInsert;
|
||||||
63
src/lib/stores/language.svelte.ts
Normal file
63
src/lib/stores/language.svelte.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { translations, type LanguageCode } from '$lib/i18n/translations';
|
||||||
|
import type { Translation } from '$lib/i18n/translations/en';
|
||||||
|
|
||||||
|
const LANGUAGE_KEY = 'preferred-language';
|
||||||
|
|
||||||
|
function getStoredLanguage(): LanguageCode {
|
||||||
|
if (typeof window === 'undefined') return 'en';
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(LANGUAGE_KEY);
|
||||||
|
if (stored && (stored === 'en' || stored === 'da')) {
|
||||||
|
return stored as LanguageCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to detect from browser
|
||||||
|
const browserLang = navigator.language.toLowerCase();
|
||||||
|
if (browserLang.startsWith('da')) {
|
||||||
|
return 'da';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
class LanguageStore {
|
||||||
|
private _current = $state<LanguageCode>(getStoredLanguage());
|
||||||
|
|
||||||
|
get current(): LanguageCode {
|
||||||
|
return this._current;
|
||||||
|
}
|
||||||
|
|
||||||
|
set current(value: LanguageCode) {
|
||||||
|
this._current = value;
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem(LANGUAGE_KEY, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get t(): Translation {
|
||||||
|
return translations[this._current];
|
||||||
|
}
|
||||||
|
|
||||||
|
setLanguage(lang: LanguageCode) {
|
||||||
|
this.current = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const languageStore = new LanguageStore();
|
||||||
|
|
||||||
|
// Helper function to get nested translation value
|
||||||
|
export function t(path: string): string {
|
||||||
|
const keys = path.split('.');
|
||||||
|
let value: any = languageStore.t;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (value && typeof value === 'object' && key in value) {
|
||||||
|
value = value[key];
|
||||||
|
} else {
|
||||||
|
console.warn(`Translation key not found: ${path}`);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === 'string' ? value : path;
|
||||||
|
}
|
||||||
63
src/lib/stores/theme.svelte.ts
Normal file
63
src/lib/stores/theme.svelte.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
class ThemeStore {
|
||||||
|
current = $state<Theme>('system');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (browser) {
|
||||||
|
const stored = localStorage.getItem('theme') as Theme | null;
|
||||||
|
this.current = stored || 'system';
|
||||||
|
this.applyTheme();
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
mediaQuery.addEventListener('change', () => {
|
||||||
|
// Re-apply theme if in system mode
|
||||||
|
if (this.current === 'system') {
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTheme() {
|
||||||
|
if (!browser) return;
|
||||||
|
|
||||||
|
const isDark = this.current === 'dark' ||
|
||||||
|
(this.current === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
// Cycle through: light -> dark -> system -> light
|
||||||
|
if (this.current === 'light') {
|
||||||
|
this.current = 'dark';
|
||||||
|
} else if (this.current === 'dark') {
|
||||||
|
this.current = 'system';
|
||||||
|
} else {
|
||||||
|
this.current = 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('theme', this.current);
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set(theme: Theme) {
|
||||||
|
this.current = theme;
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('theme', this.current);
|
||||||
|
}
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themeStore = new ThemeStore();
|
||||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||||
|
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||||
|
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||||
18
src/lib/utils/colors.ts
Normal file
18
src/lib/utils/colors.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Convert hex color to rgba with transparency
|
||||||
|
*/
|
||||||
|
export function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate card style string with color, transparency, and blur
|
||||||
|
*/
|
||||||
|
export function getCardStyle(color: string | null): string {
|
||||||
|
if (!color) return '';
|
||||||
|
|
||||||
|
return `background-color: ${hexToRgba(color, 0.2)} !important; backdrop-filter: blur(10px) !important; -webkit-backdrop-filter: blur(10px) !important;`;
|
||||||
|
}
|
||||||
@@ -9,4 +9,6 @@
|
|||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{@render children()}
|
<div class="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
8
src/routes/+page.server.ts
Normal file
8
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
return {
|
||||||
|
session
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -4,10 +4,16 @@
|
|||||||
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import ColorPicker from '$lib/components/ui/ColorPicker.svelte';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
let title = $state('');
|
let title = $state('');
|
||||||
let description = $state('');
|
let description = $state('');
|
||||||
|
let color = $state<string | null>(null);
|
||||||
let isCreating = $state(false);
|
let isCreating = $state(false);
|
||||||
|
|
||||||
async function createWishlist() {
|
async function createWishlist() {
|
||||||
@@ -18,7 +24,7 @@
|
|||||||
const response = await fetch('/api/wishlists', {
|
const response = await fetch('/api/wishlists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title, description })
|
body: JSON.stringify({ title, description, color })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -33,16 +39,28 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-slate-50 to-slate-100">
|
<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>
|
||||||
<CardTitle class="text-3xl">Create Your Wishlist</CardTitle>
|
<div class="flex items-center justify-between">
|
||||||
<CardDescription>
|
<div>
|
||||||
Create a wishlist and share it with friends and family
|
<CardTitle class="text-3xl">Create Your Wishlist</CardTitle>
|
||||||
</CardDescription>
|
<CardDescription>
|
||||||
|
Create a wishlist and share it with friends and family
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
{#if data.session?.user}
|
||||||
|
<Button variant="outline" onclick={() => goto('/dashboard')}>Dashboard</Button>
|
||||||
|
{:else}
|
||||||
|
<Button variant="outline" onclick={() => goto('/signin')}>Sign In</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form on:submit|preventDefault={createWishlist} class="space-y-4">
|
<form onsubmit={(e) => { e.preventDefault(); createWishlist(); }} class="space-y-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="title">Wishlist Title</Label>
|
<Label for="title">Wishlist Title</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -61,6 +79,12 @@
|
|||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<Label for="color">Wishlist Color (optional)</Label>
|
||||||
|
<ColorPicker bind:color={color} size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
|
<Button type="submit" class="w-full" disabled={isCreating || !title.trim()}>
|
||||||
{isCreating ? 'Creating...' : 'Create Wishlist'}
|
{isCreating ? 'Creating...' : 'Create Wishlist'}
|
||||||
</Button>
|
</Button>
|
||||||
75
src/routes/api/scrape-images/+server.ts
Normal file
75
src/routes/api/scrape-images/+server.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
const { url } = await request.json();
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return json({ error: 'URL is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return json({ error: 'Failed to fetch URL' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const baseUrl = new URL(url);
|
||||||
|
const origin = baseUrl.origin;
|
||||||
|
|
||||||
|
const imageUrls: string[] = [];
|
||||||
|
const imgRegex = /<img[^>]+src="([^">]+)"/g;
|
||||||
|
const ogImageRegex = /<meta[^>]+property="og:image"[^>]+content="([^">]+)"/g;
|
||||||
|
const twitterImageRegex = /<meta[^>]+name="twitter:image"[^>]+content="([^">]+)"/g;
|
||||||
|
|
||||||
|
function toAbsoluteUrl(imgUrl: string): string {
|
||||||
|
if (imgUrl.startsWith('http')) {
|
||||||
|
return imgUrl;
|
||||||
|
}
|
||||||
|
if (imgUrl.startsWith('//')) {
|
||||||
|
return `https:${imgUrl}`;
|
||||||
|
}
|
||||||
|
if (imgUrl.startsWith('/')) {
|
||||||
|
return `${origin}${imgUrl}`;
|
||||||
|
}
|
||||||
|
return `${origin}/${imgUrl}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = ogImageRegex.exec(html)) !== null) {
|
||||||
|
imageUrls.push(toAbsoluteUrl(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((match = twitterImageRegex.exec(html)) !== null) {
|
||||||
|
imageUrls.push(toAbsoluteUrl(match[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((match = imgRegex.exec(html)) !== null) {
|
||||||
|
const imgUrl = match[1];
|
||||||
|
const fullUrl = toAbsoluteUrl(imgUrl);
|
||||||
|
if (!imageUrls.includes(fullUrl)) {
|
||||||
|
imageUrls.push(fullUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredImages = imageUrls.filter(
|
||||||
|
(url) =>
|
||||||
|
!url.includes('logo') &&
|
||||||
|
!url.includes('icon') &&
|
||||||
|
!url.includes('sprite') &&
|
||||||
|
!url.endsWith('.svg') &&
|
||||||
|
url.length < 500
|
||||||
|
);
|
||||||
|
|
||||||
|
return json({ images: filteredImages.slice(0, 20) });
|
||||||
|
} catch (error) {
|
||||||
|
return json({ error: 'Failed to scrape images' }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -4,13 +4,16 @@ import { db } from '$lib/server/db';
|
|||||||
import { wishlists } from '$lib/server/schema';
|
import { wishlists } from '$lib/server/schema';
|
||||||
import { createId } from '@paralleldrive/cuid2';
|
import { createId } from '@paralleldrive/cuid2';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ request }) => {
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
const { title, description } = await request.json();
|
const { title, description, color } = await request.json();
|
||||||
|
|
||||||
if (!title?.trim()) {
|
if (!title?.trim()) {
|
||||||
return json({ error: 'Title is required' }, { status: 400 });
|
return json({ error: 'Title is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
const userId = session?.user?.id || null;
|
||||||
|
|
||||||
const ownerToken = createId();
|
const ownerToken = createId();
|
||||||
const publicToken = createId();
|
const publicToken = createId();
|
||||||
|
|
||||||
@@ -19,8 +22,10 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
.values({
|
.values({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description?.trim() || null,
|
description: description?.trim() || null,
|
||||||
|
color: color?.trim() || null,
|
||||||
ownerToken,
|
ownerToken,
|
||||||
publicToken
|
publicToken,
|
||||||
|
userId
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
111
src/routes/dashboard/+page.server.ts
Normal file
111
src/routes/dashboard/+page.server.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { wishlists, savedWishlists } from '$lib/server/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw redirect(303, '/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userWishlists = await db.query.wishlists.findMany({
|
||||||
|
where: eq(wishlists.userId, session.user.id),
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
|
},
|
||||||
|
user: true
|
||||||
|
},
|
||||||
|
orderBy: (wishlists, { desc }) => [desc(wishlists.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await db.query.savedWishlists.findMany({
|
||||||
|
where: eq(savedWishlists.userId, session.user.id),
|
||||||
|
with: {
|
||||||
|
wishlist: {
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
|
},
|
||||||
|
user: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orderBy: (savedWishlists, { desc }) => [desc(savedWishlists.createdAt)]
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: session.user,
|
||||||
|
wishlists: userWishlists,
|
||||||
|
savedWishlists: saved
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
toggleFavorite: async ({ request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw redirect(303, '/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const wishlistId = formData.get('wishlistId') as string;
|
||||||
|
const isFavorite = formData.get('isFavorite') === 'true';
|
||||||
|
|
||||||
|
if (!wishlistId) {
|
||||||
|
return { success: false, error: 'Wishlist ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(wishlists)
|
||||||
|
.set({ isFavorite: !isFavorite })
|
||||||
|
.where(eq(wishlists.id, wishlistId));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSavedFavorite: async ({ request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw redirect(303, '/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||||
|
const isFavorite = formData.get('isFavorite') === 'true';
|
||||||
|
|
||||||
|
if (!savedWishlistId) {
|
||||||
|
return { success: false, error: 'Saved wishlist ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(savedWishlists)
|
||||||
|
.set({ isFavorite: !isFavorite })
|
||||||
|
.where(eq(savedWishlists.id, savedWishlistId));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
unsaveWishlist: async ({ request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
throw redirect(303, '/signin');
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||||
|
|
||||||
|
if (!savedWishlistId) {
|
||||||
|
return { success: false, error: 'Saved wishlist ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(savedWishlists)
|
||||||
|
.where(and(
|
||||||
|
eq(savedWishlists.id, savedWishlistId),
|
||||||
|
eq(savedWishlists.userId, session.user.id)
|
||||||
|
));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
189
src/routes/dashboard/+page.svelte
Normal file
189
src/routes/dashboard/+page.svelte
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import PageContainer from '$lib/components/layout/PageContainer.svelte';
|
||||||
|
import DashboardHeader from '$lib/components/layout/DashboardHeader.svelte';
|
||||||
|
import WishlistGrid from '$lib/components/dashboard/WishlistGrid.svelte';
|
||||||
|
import WishlistCard from '$lib/components/dashboard/WishlistCard.svelte';
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { Star } from 'lucide-svelte';
|
||||||
|
import { languageStore } from '$lib/stores/language.svelte';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
const t = $derived(languageStore.t);
|
||||||
|
|
||||||
|
const sortedWishlists = $derived(
|
||||||
|
[...data.wishlists].sort((a, b) => {
|
||||||
|
if (a.isFavorite && !b.isFavorite) return -1;
|
||||||
|
if (!a.isFavorite && b.isFavorite) return 1;
|
||||||
|
|
||||||
|
const aHasEndDate = !!a.endDate;
|
||||||
|
const bHasEndDate = !!b.endDate;
|
||||||
|
|
||||||
|
if (aHasEndDate && !bHasEndDate) return -1;
|
||||||
|
if (!aHasEndDate && bHasEndDate) return 1;
|
||||||
|
|
||||||
|
if (aHasEndDate && bHasEndDate) {
|
||||||
|
return new Date(a.endDate!).getTime() - new Date(b.endDate!).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedSavedWishlists = $derived(
|
||||||
|
[...data.savedWishlists].sort((a, b) => {
|
||||||
|
if (a.isFavorite && !b.isFavorite) return -1;
|
||||||
|
if (!a.isFavorite && b.isFavorite) return 1;
|
||||||
|
|
||||||
|
const aHasEndDate = !!a.wishlist?.endDate;
|
||||||
|
const bHasEndDate = !!b.wishlist?.endDate;
|
||||||
|
|
||||||
|
if (aHasEndDate && !bHasEndDate) return -1;
|
||||||
|
if (!aHasEndDate && bHasEndDate) return 1;
|
||||||
|
|
||||||
|
if (aHasEndDate && bHasEndDate) {
|
||||||
|
return new Date(a.wishlist.endDate!).getTime() - new Date(b.wishlist.endDate!).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatEndDate(date: Date | string | null): string | null {
|
||||||
|
if (!date) return null;
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString(languageStore.t.date.format.short, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWishlistDescription(wishlist: any): string | null {
|
||||||
|
if (!wishlist) return null;
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
const topItems = wishlist.items?.slice(0, 3).map((item: any) => item.title) || [];
|
||||||
|
if (topItems.length > 0) {
|
||||||
|
lines.push(topItems.join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wishlist.user?.name || wishlist.user?.username) {
|
||||||
|
const ownerName = wishlist.user.name || wishlist.user.username;
|
||||||
|
lines.push(`${t.dashboard.by} ${ownerName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wishlist.endDate) {
|
||||||
|
lines.push(`${t.dashboard.ends}: ${formatEndDate(wishlist.endDate)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length > 0 ? lines.join('\n') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSavedWishlistDescription(saved: any): string | null {
|
||||||
|
return getWishlistDescription(saved.wishlist);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageContainer>
|
||||||
|
<DashboardHeader userName={data.user?.name} userEmail={data.user?.email} />
|
||||||
|
|
||||||
|
<WishlistGrid
|
||||||
|
title={t.dashboard.myWishlists}
|
||||||
|
description={t.dashboard.myWishlistsDescription}
|
||||||
|
items={sortedWishlists || []}
|
||||||
|
emptyMessage={t.dashboard.emptyWishlists}
|
||||||
|
emptyActionLabel={t.dashboard.emptyWishlistsAction}
|
||||||
|
emptyActionHref="/"
|
||||||
|
>
|
||||||
|
{#snippet headerAction()}
|
||||||
|
<Button onclick={() => (window.location.href = '/')}>{t.dashboard.createNew}</Button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet children(wishlist)}
|
||||||
|
<WishlistCard
|
||||||
|
title={wishlist.title}
|
||||||
|
description={getWishlistDescription(wishlist)}
|
||||||
|
itemCount={wishlist.items?.length || 0}
|
||||||
|
color={wishlist.color}
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<form method="POST" action="?/toggleFavorite" use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="wishlistId" value={wishlist.id} />
|
||||||
|
<input type="hidden" name="isFavorite" value={wishlist.isFavorite} />
|
||||||
|
<Button type="submit" size="sm" variant="outline">
|
||||||
|
<Star class={wishlist.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (window.location.href = `/wishlist/${wishlist.ownerToken}/edit`)}
|
||||||
|
>
|
||||||
|
{t.dashboard.manage}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${window.location.origin}/wishlist/${wishlist.publicToken}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.dashboard.copyLink}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</WishlistCard>
|
||||||
|
{/snippet}
|
||||||
|
</WishlistGrid>
|
||||||
|
|
||||||
|
<WishlistGrid
|
||||||
|
title={t.dashboard.savedWishlists}
|
||||||
|
description={t.dashboard.savedWishlistsDescription}
|
||||||
|
items={sortedSavedWishlists || []}
|
||||||
|
emptyMessage={t.dashboard.emptySavedWishlists}
|
||||||
|
emptyDescription={t.dashboard.emptySavedWishlistsDescription}
|
||||||
|
>
|
||||||
|
{#snippet children(saved)}
|
||||||
|
<WishlistCard
|
||||||
|
title={saved.wishlist?.title}
|
||||||
|
description={getSavedWishlistDescription(saved)}
|
||||||
|
itemCount={saved.wishlist?.items?.length || 0}
|
||||||
|
color={saved.wishlist?.color}
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<form method="POST" action="?/toggleSavedFavorite" use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
||||||
|
<input type="hidden" name="isFavorite" value={saved.isFavorite} />
|
||||||
|
<Button type="submit" size="sm" variant="outline">
|
||||||
|
<Star class={saved.isFavorite ? "fill-yellow-500 text-yellow-500" : ""} />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (window.location.href = `/wishlist/${saved.wishlist.publicToken}`)}
|
||||||
|
>
|
||||||
|
{t.dashboard.viewWishlist}
|
||||||
|
</Button>
|
||||||
|
<form method="POST" action="?/unsaveWishlist" use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}>
|
||||||
|
<input type="hidden" name="savedWishlistId" value={saved.id} />
|
||||||
|
<Button type="submit" size="sm" variant="destructive">
|
||||||
|
{t.dashboard.unsave}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</WishlistCard>
|
||||||
|
{/snippet}
|
||||||
|
</WishlistGrid>
|
||||||
|
</PageContainer>
|
||||||
24
src/routes/signin/+page.server.ts
Normal file
24
src/routes/signin/+page.server.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => {
|
||||||
|
const registered = url.searchParams.get('registered');
|
||||||
|
const error = url.searchParams.get('error');
|
||||||
|
|
||||||
|
// Determine which OAuth providers are available
|
||||||
|
const oauthProviders = [];
|
||||||
|
|
||||||
|
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||||
|
oauthProviders.push({ id: 'google', name: 'Google' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
||||||
|
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
registered: registered === 'true',
|
||||||
|
error: error,
|
||||||
|
oauthProviders
|
||||||
|
};
|
||||||
|
};
|
||||||
102
src/routes/signin/+page.svelte
Normal file
102
src/routes/signin/+page.svelte
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import type { PageData } from './$types';
|
||||||
|
import { signIn } from '@auth/sveltekit/client';
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
isSubmitting = true;
|
||||||
|
|
||||||
|
const formData = new FormData(e.target as HTMLFormElement);
|
||||||
|
const username = formData.get('username') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await signIn('credentials', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
callbackUrl: '/dashboard'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sign in error:', error);
|
||||||
|
} finally {
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<Card class="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-2xl">Welcome Back</CardTitle>
|
||||||
|
<CardDescription>Sign in to your account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
{#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">
|
||||||
|
Account created successfully! Please sign in.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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">
|
||||||
|
Invalid username or password
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit} class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="username">Username</Label>
|
||||||
|
<Input id="username" name="username" type="text" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">Password</Label>
|
||||||
|
<Input id="password" name="password" type="password" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" class="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if data.oauthProviders.length > 0}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<span class="w-full border-t"></span>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-xs uppercase">
|
||||||
|
<span class="bg-card px-2 text-muted-foreground">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each data.oauthProviders as provider}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full"
|
||||||
|
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
||||||
|
>
|
||||||
|
Sign in with {provider.name}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
|
Don't have an account?
|
||||||
|
<a href="/signup" class="text-primary hover:underline">Sign up</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
68
src/routes/signup/+page.server.ts
Normal file
68
src/routes/signup/+page.server.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { users } from '$lib/server/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
// Determine which OAuth providers are available
|
||||||
|
const oauthProviders = [];
|
||||||
|
|
||||||
|
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||||
|
oauthProviders.push({ id: 'google', name: 'Google' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.AUTHENTIK_CLIENT_ID && env.AUTHENTIK_CLIENT_SECRET && env.AUTHENTIK_ISSUER) {
|
||||||
|
oauthProviders.push({ id: 'authentik', name: 'Authentik' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oauthProviders
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get('name') as string;
|
||||||
|
const username = formData.get('username') as string;
|
||||||
|
const password = formData.get('password') as string;
|
||||||
|
const confirmPassword = formData.get('confirmPassword') as string;
|
||||||
|
|
||||||
|
if (!name?.trim()) {
|
||||||
|
return fail(400, { error: 'Name is required', name, username });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username?.trim()) {
|
||||||
|
return fail(400, { error: 'Username is required', name, username });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
return fail(400, { error: 'Password must be at least 8 characters', name, username });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return fail(400, { error: 'Passwords do not match', name, username });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await db.query.users.findFirst({
|
||||||
|
where: eq(users.username, username.trim().toLowerCase())
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return fail(400, { error: 'Username already taken', name, username });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
name: name.trim(),
|
||||||
|
username: username.trim().toLowerCase(),
|
||||||
|
password: hashedPassword
|
||||||
|
});
|
||||||
|
|
||||||
|
throw redirect(303, '/signin?registered=true');
|
||||||
|
}
|
||||||
|
};
|
||||||
81
src/routes/signup/+page.svelte
Normal file
81
src/routes/signup/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
|
import { Input } from '$lib/components/ui/input';
|
||||||
|
import { Label } from '$lib/components/ui/label';
|
||||||
|
import { ThemeToggle } from '$lib/components/ui/theme-toggle';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
import { signIn } from '@auth/sveltekit/client';
|
||||||
|
|
||||||
|
let { form, data }: { form: ActionData; data: PageData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="absolute top-4 right-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
<Card class="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-2xl">Create an Account</CardTitle>
|
||||||
|
<CardDescription>Sign up to manage your wishlists</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
{#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">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="name">Name</Label>
|
||||||
|
<Input id="name" name="name" type="text" required value={form?.name || ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="username">Username</Label>
|
||||||
|
<Input id="username" name="username" type="text" required value={form?.username || ''} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="password">Password</Label>
|
||||||
|
<Input id="password" name="password" type="password" required minlength={8} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input id="confirmPassword" name="confirmPassword" type="password" required minlength={8} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" class="w-full">Sign Up</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if data.oauthProviders.length > 0}
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 flex items-center">
|
||||||
|
<span class="w-full border-t"></span>
|
||||||
|
</div>
|
||||||
|
<div class="relative flex justify-center text-xs uppercase">
|
||||||
|
<span class="bg-card px-2 text-muted-foreground">Or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#each data.oauthProviders as provider}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
class="w-full"
|
||||||
|
onclick={() => signIn(provider.id, { callbackUrl: '/dashboard' })}
|
||||||
|
>
|
||||||
|
Sign up with {provider.name}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="text-center text-sm text-muted-foreground">
|
||||||
|
Already have an account?
|
||||||
|
<a href="/signin" class="text-primary hover:underline">Sign in</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
158
src/routes/wishlist/[token]/+page.server.ts
Normal file
158
src/routes/wishlist/[token]/+page.server.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { wishlists, items, reservations, savedWishlists } from '$lib/server/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.publicToken, params.token),
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
orderBy: (items, { asc }) => [asc(items.order)],
|
||||||
|
with: {
|
||||||
|
reservations: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
let isSaved = false;
|
||||||
|
let savedWishlistId: string | null = null;
|
||||||
|
|
||||||
|
if (session?.user?.id) {
|
||||||
|
const saved = await db.query.savedWishlists.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(savedWishlists.userId, session.user.id),
|
||||||
|
eq(savedWishlists.wishlistId, wishlist.id)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
isSaved = !!saved;
|
||||||
|
savedWishlistId = saved?.id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wishlist,
|
||||||
|
isSaved,
|
||||||
|
savedWishlistId,
|
||||||
|
isAuthenticated: !!session?.user
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
reserve: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get('itemId') as string;
|
||||||
|
const reserverName = formData.get('reserverName') as string;
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return { success: false, error: 'Item ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReservation = await db.query.reservations.findFirst({
|
||||||
|
where: eq(reservations.itemId, itemId)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingReservation) {
|
||||||
|
return { success: false, error: 'This item is already reserved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(reservations).values({
|
||||||
|
itemId,
|
||||||
|
reserverName: reserverName?.trim() || null
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(items)
|
||||||
|
.set({ isReserved: true })
|
||||||
|
.where(eq(items.id, itemId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
unreserve: async ({ request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get('itemId') as string;
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return { success: false, error: 'Item ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.delete(reservations).where(eq(reservations.itemId, itemId));
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(items)
|
||||||
|
.set({ isReserved: false })
|
||||||
|
.where(eq(items.id, itemId));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveWishlist: async ({ request, locals, params }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'You must be logged in to save wishlists' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const wishlistId = formData.get('wishlistId') as string;
|
||||||
|
|
||||||
|
if (!wishlistId) {
|
||||||
|
return { success: false, error: 'Wishlist ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.savedWishlists.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(savedWishlists.userId, session.user.id),
|
||||||
|
eq(savedWishlists.wishlistId, wishlistId)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: 'Wishlist already saved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(savedWishlists).values({
|
||||||
|
userId: session.user.id,
|
||||||
|
wishlistId
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
unsaveWishlist: async ({ request, locals }) => {
|
||||||
|
const session = await locals.auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return { success: false, error: 'You must be logged in' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const savedWishlistId = formData.get('savedWishlistId') as string;
|
||||||
|
|
||||||
|
if (!savedWishlistId) {
|
||||||
|
return { success: false, error: 'Saved wishlist ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(savedWishlists)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(savedWishlists.id, savedWishlistId),
|
||||||
|
eq(savedWishlists.userId, session.user.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
137
src/routes/wishlist/[token]/+page.svelte
Normal file
137
src/routes/wishlist/[token]/+page.svelte
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "$lib/components/ui/card";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Label } from "$lib/components/ui/label";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import WishlistItem from "$lib/components/wishlist/WishlistItem.svelte";
|
||||||
|
import ReservationButton from "$lib/components/wishlist/ReservationButton.svelte";
|
||||||
|
import PageContainer from "$lib/components/layout/PageContainer.svelte";
|
||||||
|
import Navigation from "$lib/components/layout/Navigation.svelte";
|
||||||
|
import EmptyState from "$lib/components/layout/EmptyState.svelte";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
import { getCardStyle } from "$lib/utils/colors";
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let showSaveForm = $state(false);
|
||||||
|
|
||||||
|
const headerCardStyle = $derived(getCardStyle(data.wishlist.color));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageContainer maxWidth="4xl">
|
||||||
|
<Navigation
|
||||||
|
isAuthenticated={data.isAuthenticated}
|
||||||
|
showDashboardLink={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<Card style={headerCardStyle}>
|
||||||
|
<CardContent class="pt-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<CardTitle class="text-3xl">{data.wishlist.title}</CardTitle>
|
||||||
|
{#if data.wishlist.description}
|
||||||
|
<CardDescription class="text-base"
|
||||||
|
>{data.wishlist.description}</CardDescription
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if data.isAuthenticated}
|
||||||
|
{#if data.isSaved}
|
||||||
|
<form method="POST" action="?/unsaveWishlist" use:enhance>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="savedWishlistId"
|
||||||
|
value={data.savedWishlistId}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="outline" size="sm">
|
||||||
|
Unsave
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (showSaveForm = !showSaveForm)}
|
||||||
|
>
|
||||||
|
{showSaveForm ? "Cancel" : "Save Wishlist"}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => (window.location.href = "/signin")}
|
||||||
|
>
|
||||||
|
Sign in to Save
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Save Confirmation -->
|
||||||
|
{#if showSaveForm && !data.isSaved}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Save This Wishlist</CardTitle>
|
||||||
|
<CardDescription
|
||||||
|
>Save this wishlist to easily find it later in your dashboard</CardDescription
|
||||||
|
>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/saveWishlist"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
showSaveForm = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="space-y-4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="wishlistId"
|
||||||
|
value={data.wishlist.id}
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="submit">Save Wishlist</Button>
|
||||||
|
<Button type="button" variant="outline" onclick={() => showSaveForm = false}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Items List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#if data.wishlist.items && data.wishlist.items.length > 0}
|
||||||
|
{#each data.wishlist.items as item}
|
||||||
|
<WishlistItem {item}>
|
||||||
|
<ReservationButton
|
||||||
|
itemId={item.id}
|
||||||
|
isReserved={item.isReserved}
|
||||||
|
reserverName={item.reservations?.[0]?.reserverName}
|
||||||
|
/>
|
||||||
|
</WishlistItem>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<Card>
|
||||||
|
<CardContent class="p-12">
|
||||||
|
<EmptyState
|
||||||
|
message="This wishlist doesn't have any items yet."
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
228
src/routes/wishlist/[token]/edit/+page.server.ts
Normal file
228
src/routes/wishlist/[token]/edit/+page.server.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { wishlists, items } from '$lib/server/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals }) => {
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token),
|
||||||
|
with: {
|
||||||
|
items: {
|
||||||
|
orderBy: (items, { asc }) => [asc(items.order)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await locals.auth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
wishlist,
|
||||||
|
publicUrl: `/wishlist/${wishlist.publicToken}`,
|
||||||
|
isAuthenticated: !!session?.user
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addItem: async ({ params, request }) => {
|
||||||
|
const formData = await request.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 (!title?.trim()) {
|
||||||
|
return { success: false, error: 'Title is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token),
|
||||||
|
with: {
|
||||||
|
items: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the max order value and add 1
|
||||||
|
const maxOrder = wishlist.items.reduce((max, item) => {
|
||||||
|
const order = Number(item.order) || 0;
|
||||||
|
return order > max ? order : max;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
await db.insert(items).values({
|
||||||
|
wishlistId: wishlist.id,
|
||||||
|
title: title.trim(),
|
||||||
|
description: description?.trim() || null,
|
||||||
|
link: link?.trim() || null,
|
||||||
|
imageUrl: imageUrl?.trim() || null,
|
||||||
|
price: price ? price.trim() : null,
|
||||||
|
currency: currency?.trim() || 'DKK',
|
||||||
|
color: color?.trim() || null,
|
||||||
|
order: String(maxOrder + 1)
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateItem: async ({ params, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemId = formData.get('itemId') as string;
|
||||||
|
const title = formData.get('title') as string;
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
const link = formData.get('link') as string;
|
||||||
|
const imageUrl = formData.get('imageUrl') as string;
|
||||||
|
const price = formData.get('price') as string;
|
||||||
|
const currency = formData.get('currency') as string;
|
||||||
|
const color = formData.get('color') as string;
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return { success: false, error: 'Item ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title?.trim()) {
|
||||||
|
return { success: false, error: 'Title is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(items)
|
||||||
|
.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;
|
||||||
|
|
||||||
|
if (!itemId) {
|
||||||
|
return { success: false, error: 'Item ID is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(items).where(eq(items.id, itemId));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderItems: async ({ params, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const itemsJson = formData.get('items') as string;
|
||||||
|
|
||||||
|
if (!itemsJson) {
|
||||||
|
return { success: false, error: 'Items data is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = JSON.parse(itemsJson) as Array<{ id: string; order: number }>;
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
await db.update(items)
|
||||||
|
.set({ order: String(update.order), updatedAt: new Date() })
|
||||||
|
.where(eq(items.id, update.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteWishlist: async ({ params }) => {
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(wishlists).where(eq(wishlists.id, wishlist.id));
|
||||||
|
|
||||||
|
return { success: true, redirect: '/dashboard' };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWishlist: async ({ params, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const color = formData.get('color');
|
||||||
|
const title = formData.get('title');
|
||||||
|
const description = formData.get('description');
|
||||||
|
const endDate = formData.get('endDate');
|
||||||
|
|
||||||
|
const wishlist = await db.query.wishlists.findFirst({
|
||||||
|
where: eq(wishlists.ownerToken, params.token)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!wishlist) {
|
||||||
|
throw error(404, 'Wishlist not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: any = {
|
||||||
|
updatedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (color !== null) {
|
||||||
|
updates.color = color?.toString().trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title !== null) {
|
||||||
|
const titleStr = title?.toString().trim();
|
||||||
|
if (!titleStr) {
|
||||||
|
return { success: false, error: 'Title is required' };
|
||||||
|
}
|
||||||
|
updates.title = titleStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (description !== null) {
|
||||||
|
updates.description = description?.toString().trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate !== null) {
|
||||||
|
const endDateStr = endDate?.toString().trim();
|
||||||
|
updates.endDate = endDateStr ? new Date(endDateStr) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.update(wishlists)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(wishlists.id, wishlist.id));
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
300
src/routes/wishlist/[token]/edit/+page.svelte
Normal file
300
src/routes/wishlist/[token]/edit/+page.svelte
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
import AddItemForm from "$lib/components/wishlist/AddItemForm.svelte";
|
||||||
|
import EditItemForm from "$lib/components/wishlist/EditItemForm.svelte";
|
||||||
|
import ShareLinks from "$lib/components/wishlist/ShareLinks.svelte";
|
||||||
|
import PageContainer from "$lib/components/layout/PageContainer.svelte";
|
||||||
|
import Navigation from "$lib/components/layout/Navigation.svelte";
|
||||||
|
import WishlistHeader from "$lib/components/wishlist/WishlistHeader.svelte";
|
||||||
|
import WishlistActionButtons from "$lib/components/wishlist/WishlistActionButtons.svelte";
|
||||||
|
import EditableItemsList from "$lib/components/wishlist/EditableItemsList.svelte";
|
||||||
|
import type { Item } from "$lib/server/schema";
|
||||||
|
import { Input } from "$lib/components/ui/input";
|
||||||
|
import { Button } from "$lib/components/ui/button";
|
||||||
|
import { Search, Lock, LockOpen } from "lucide-svelte";
|
||||||
|
import { enhance } from "$app/forms";
|
||||||
|
|
||||||
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
let rearranging = $state(false);
|
||||||
|
let editingItem = $state<Item | null>(null);
|
||||||
|
let addFormElement = $state<HTMLElement | null>(null);
|
||||||
|
let editFormElement = $state<HTMLElement | null>(null);
|
||||||
|
let searchQuery = $state("");
|
||||||
|
|
||||||
|
let sortedItems = $state<Item[]>([]);
|
||||||
|
let filteredItems = $derived(
|
||||||
|
searchQuery.trim()
|
||||||
|
? sortedItems.filter(item =>
|
||||||
|
item.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
item.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
: sortedItems
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
sortedItems = [...data.wishlist.items].sort(
|
||||||
|
(a, b) => Number(a.order) - Number(b.order),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleItemAdded() {
|
||||||
|
showAddForm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemUpdated() {
|
||||||
|
editingItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditing(item: Item) {
|
||||||
|
editingItem = item;
|
||||||
|
showAddForm = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
editFormElement?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleColorChange(itemId: string, newColor: string) {
|
||||||
|
sortedItems = sortedItems.map((item) =>
|
||||||
|
item.id === itemId ? { ...item, color: newColor } : item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditing() {
|
||||||
|
editingItem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(items: Item[]) {
|
||||||
|
const updates = items.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
order: index,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetch("?/reorderItems", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
items: JSON.stringify(updates),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to update item order");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTitleUpdate(title: string): Promise<boolean> {
|
||||||
|
const response = await fetch("?/updateWishlist", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
title: title,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to update wishlist title");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDescriptionUpdate(description: string | null): Promise<boolean> {
|
||||||
|
const response = await fetch("?/updateWishlist", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
description: description || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to update wishlist description");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleColorUpdate(color: string | null) {
|
||||||
|
const response = await fetch("?/updateWishlist", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
color: color || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to update wishlist color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEndDateUpdate(endDate: string | null) {
|
||||||
|
const response = await fetch("?/updateWishlist", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
endDate: endDate || "",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error("Failed to update wishlist end date");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleAddForm() {
|
||||||
|
showAddForm = !showAddForm;
|
||||||
|
if (showAddForm) {
|
||||||
|
setTimeout(() => {
|
||||||
|
addFormElement?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePositionChange(newPosition: number) {
|
||||||
|
if (!editingItem) return;
|
||||||
|
|
||||||
|
const currentIndex = sortedItems.findIndex(item => item.id === editingItem.id);
|
||||||
|
if (currentIndex === -1) return;
|
||||||
|
|
||||||
|
const newIndex = newPosition - 1; // Convert to 0-based index
|
||||||
|
|
||||||
|
// Reorder the array
|
||||||
|
const newItems = [...sortedItems];
|
||||||
|
const [movedItem] = newItems.splice(currentIndex, 1);
|
||||||
|
newItems.splice(newIndex, 0, movedItem);
|
||||||
|
|
||||||
|
sortedItems = newItems;
|
||||||
|
await handleReorder(newItems);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<PageContainer maxWidth="4xl">
|
||||||
|
<Navigation
|
||||||
|
isAuthenticated={data.isAuthenticated}
|
||||||
|
showDashboardLink={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WishlistHeader
|
||||||
|
wishlist={data.wishlist}
|
||||||
|
onTitleUpdate={handleTitleUpdate}
|
||||||
|
onDescriptionUpdate={handleDescriptionUpdate}
|
||||||
|
onColorUpdate={handleColorUpdate}
|
||||||
|
onEndDateUpdate={handleEndDateUpdate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareLinks
|
||||||
|
publicUrl={data.publicUrl}
|
||||||
|
ownerUrl="/wishlist/{data.wishlist.ownerToken}/edit"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WishlistActionButtons
|
||||||
|
bind:rearranging={rearranging}
|
||||||
|
onToggleAddForm={handleToggleAddForm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if showAddForm}
|
||||||
|
<div bind:this={addFormElement}>
|
||||||
|
<AddItemForm onSuccess={handleItemAdded} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editingItem}
|
||||||
|
<div bind:this={editFormElement}>
|
||||||
|
<EditItemForm
|
||||||
|
item={editingItem}
|
||||||
|
onSuccess={handleItemUpdated}
|
||||||
|
onCancel={cancelEditing}
|
||||||
|
onColorChange={handleColorChange}
|
||||||
|
currentPosition={sortedItems.findIndex(item => item.id === editingItem.id) + 1}
|
||||||
|
totalItems={sortedItems.length}
|
||||||
|
onPositionChange={handlePositionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if sortedItems.length > 5}
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search items..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<EditableItemsList
|
||||||
|
bind:items={filteredItems}
|
||||||
|
{rearranging}
|
||||||
|
onStartEditing={startEditing}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<Button
|
||||||
|
onclick={() => rearranging = !rearranging}
|
||||||
|
variant={rearranging ? "default" : "outline"}
|
||||||
|
class="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
{#if rearranging}
|
||||||
|
<Lock class="mr-2 h-4 w-4" />
|
||||||
|
Lock Editing
|
||||||
|
{:else}
|
||||||
|
<LockOpen class="mr-2 h-4 w-4" />
|
||||||
|
Unlock for Reordering & Deletion
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if rearranging}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteWishlist"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Are you sure you want to delete this wishlist? This action cannot be undone.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type === "success") {
|
||||||
|
window.location.href = "/dashboard";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
class="w-full md:w-auto"
|
||||||
|
>
|
||||||
|
Delete Wishlist
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Database connection string
|
|
||||||
# Example: postgresql://username:password@localhost:5432/wishlist
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/wishlist
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
# Coolify Deployment Guide
|
|
||||||
|
|
||||||
This guide will help you deploy the Wishlist app to Coolify using Docker.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Coolify instance running (self-hosted or cloud)
|
|
||||||
- PostgreSQL database (can be created in Coolify)
|
|
||||||
- Git repository (GitHub, GitLab, or Gitea)
|
|
||||||
|
|
||||||
## Step 1: Push Code to Git Repository
|
|
||||||
|
|
||||||
If you haven't already, push your code to a Git repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git init
|
|
||||||
git add .
|
|
||||||
git commit -m "Initial commit"
|
|
||||||
git remote add origin <your-repo-url>
|
|
||||||
git push -u origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 2: Set Up PostgreSQL in Coolify
|
|
||||||
|
|
||||||
1. Go to your Coolify dashboard
|
|
||||||
2. Click **+ New Resource** → **Database** → **PostgreSQL**
|
|
||||||
3. Configure:
|
|
||||||
- **Name**: `wishlist-db`
|
|
||||||
- **PostgreSQL Version**: 16 (or latest)
|
|
||||||
- Click **Create**
|
|
||||||
4. Once created, note down the connection details:
|
|
||||||
- **Host**: (internal hostname, e.g., `wishlist-db`)
|
|
||||||
- **Port**: `5432`
|
|
||||||
- **Database**: `postgres` (or create a new database)
|
|
||||||
- **Username**: `postgres`
|
|
||||||
- **Password**: (auto-generated or set your own)
|
|
||||||
|
|
||||||
## Step 3: Create the Application in Coolify
|
|
||||||
|
|
||||||
1. Click **+ New Resource** → **Application**
|
|
||||||
2. Select your Git source (GitHub, GitLab, etc.)
|
|
||||||
3. Choose your repository
|
|
||||||
4. Configure the application:
|
|
||||||
- **Branch**: `main` (or your default branch)
|
|
||||||
- **Build Pack**: Docker
|
|
||||||
- **Port**: `3000`
|
|
||||||
- **Dockerfile Location**: `./Dockerfile` (default)
|
|
||||||
|
|
||||||
## Step 4: Configure Environment Variables
|
|
||||||
|
|
||||||
In the Coolify application settings, go to **Environment Variables** and add:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@wishlist-db:5432/postgres
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important**: Replace `YOUR_PASSWORD` with the actual password from Step 2.
|
|
||||||
|
|
||||||
### Getting the Database Connection String
|
|
||||||
|
|
||||||
If you created the database in Coolify, you can find the connection string in:
|
|
||||||
1. Go to your database resource
|
|
||||||
2. Click on **Connection String** or **Environment Variables**
|
|
||||||
3. Copy the `DATABASE_URL` or construct it as:
|
|
||||||
```
|
|
||||||
postgresql://[USERNAME]:[PASSWORD]@[HOST]:[PORT]/[DATABASE]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 5: Configure Health Check (Optional but Recommended)
|
|
||||||
|
|
||||||
In Coolify application settings:
|
|
||||||
1. Go to **Health Check**
|
|
||||||
2. Set:
|
|
||||||
- **Path**: `/`
|
|
||||||
- **Port**: `3000`
|
|
||||||
- **Interval**: `30s`
|
|
||||||
|
|
||||||
## Step 6: Deploy
|
|
||||||
|
|
||||||
1. Click **Deploy** button in Coolify
|
|
||||||
2. Monitor the build logs
|
|
||||||
3. Wait for the deployment to complete
|
|
||||||
|
|
||||||
The build process will:
|
|
||||||
- Install dependencies with Bun
|
|
||||||
- Build the SvelteKit application
|
|
||||||
- Create a production-ready Docker image
|
|
||||||
- Start the application on port 3000
|
|
||||||
|
|
||||||
## Step 7: Run Database Migrations
|
|
||||||
|
|
||||||
After the first deployment, you need to set up the database schema. You have two options:
|
|
||||||
|
|
||||||
### Option A: Using Coolify Terminal (Recommended)
|
|
||||||
|
|
||||||
1. Go to your application in Coolify
|
|
||||||
2. Click **Terminal** or **Console**
|
|
||||||
3. Run:
|
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option B: Using Docker Exec
|
|
||||||
|
|
||||||
SSH into your Coolify server and run:
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-name> bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
Find the container name with:
|
|
||||||
```bash
|
|
||||||
docker ps | grep wishlist
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 8: Access Your Application
|
|
||||||
|
|
||||||
1. In Coolify, go to your application
|
|
||||||
2. You should see the generated domain (e.g., `wishlist-xyz.coolify.io`)
|
|
||||||
3. Optionally, configure a custom domain in **Domains** settings
|
|
||||||
|
|
||||||
Visit your domain and your wishlist app should be running! 🎉
|
|
||||||
|
|
||||||
## Updating the Application
|
|
||||||
|
|
||||||
For future updates:
|
|
||||||
|
|
||||||
1. Push changes to your Git repository:
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "Your changes"
|
|
||||||
git push
|
|
||||||
```
|
|
||||||
|
|
||||||
2. In Coolify, click **Deploy** to rebuild and redeploy
|
|
||||||
|
|
||||||
Or enable **Auto Deploy** in Coolify settings to deploy automatically on push.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Build Fails
|
|
||||||
|
|
||||||
**Check the build logs in Coolify for specific errors.**
|
|
||||||
|
|
||||||
Common issues:
|
|
||||||
- Missing environment variables
|
|
||||||
- Wrong Node/Bun version
|
|
||||||
- Database connection issues during build
|
|
||||||
|
|
||||||
### Application Crashes
|
|
||||||
|
|
||||||
1. Check application logs in Coolify
|
|
||||||
2. Verify `DATABASE_URL` is correct
|
|
||||||
3. Ensure database is running and accessible
|
|
||||||
4. Check if migrations were run
|
|
||||||
|
|
||||||
### Database Connection Errors
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Connection refused
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solutions:**
|
|
||||||
- Verify the database hostname (use internal Coolify network name)
|
|
||||||
- Check database is running: Go to database resource in Coolify
|
|
||||||
- Ensure application and database are in the same network/project
|
|
||||||
- Verify credentials are correct
|
|
||||||
|
|
||||||
### Port Issues
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: Port 3000 already in use
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Coolify handles port mapping automatically
|
|
||||||
- Don't change the PORT environment variable unless needed
|
|
||||||
- Check if another service is using port 3000
|
|
||||||
|
|
||||||
### Migration Errors
|
|
||||||
|
|
||||||
```
|
|
||||||
Error: relation "wishlists" does not exist
|
|
||||||
```
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
Run the database migration:
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-name> bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Environment-Specific Configuration
|
|
||||||
|
|
||||||
### Using Multiple Databases
|
|
||||||
|
|
||||||
For different environments (staging/production):
|
|
||||||
|
|
||||||
1. Create separate database resources in Coolify
|
|
||||||
2. Use different `DATABASE_URL` for each environment
|
|
||||||
3. Deploy to different branches or applications
|
|
||||||
|
|
||||||
### SSL/TLS for Database
|
|
||||||
|
|
||||||
If using an external PostgreSQL with SSL:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql://user:pass@host:5432/db?sslmode=require
|
|
||||||
```
|
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Custom Domain
|
|
||||||
|
|
||||||
1. In Coolify, go to application → **Domains**
|
|
||||||
2. Click **Add Domain**
|
|
||||||
3. Enter your domain (e.g., `wishlist.yourdomain.com`)
|
|
||||||
4. Configure DNS:
|
|
||||||
- Add A record pointing to your Coolify server IP
|
|
||||||
- Or CNAME pointing to your Coolify domain
|
|
||||||
5. Coolify will automatically configure SSL with Let's Encrypt
|
|
||||||
|
|
||||||
### Scaling
|
|
||||||
|
|
||||||
To scale your application:
|
|
||||||
|
|
||||||
1. In Coolify, adjust resources:
|
|
||||||
- **CPU Limit**
|
|
||||||
- **Memory Limit**
|
|
||||||
2. Consider using a managed PostgreSQL service for better performance
|
|
||||||
3. Enable multiple replicas (if your Coolify setup supports it)
|
|
||||||
|
|
||||||
### Backup Database
|
|
||||||
|
|
||||||
In Coolify database settings:
|
|
||||||
1. Go to **Backups**
|
|
||||||
2. Configure automatic backups
|
|
||||||
3. Set backup frequency and retention
|
|
||||||
|
|
||||||
Or manually backup:
|
|
||||||
```bash
|
|
||||||
docker exec -it <db-container> pg_dump -U postgres postgres > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Checklist
|
|
||||||
|
|
||||||
Before going live:
|
|
||||||
|
|
||||||
- [ ] Database backups configured
|
|
||||||
- [ ] Custom domain configured with SSL
|
|
||||||
- [ ] Environment variables set correctly
|
|
||||||
- [ ] Database migrations run successfully
|
|
||||||
- [ ] Health checks configured
|
|
||||||
- [ ] Application logs monitored
|
|
||||||
- [ ] Test wishlist creation and reservation flow
|
|
||||||
- [ ] Test on mobile devices
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. Check Coolify documentation: https://coolify.io/docs
|
|
||||||
2. Review application logs in Coolify dashboard
|
|
||||||
3. Verify all environment variables are set
|
|
||||||
4. Ensure database is accessible from the application
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- Set up monitoring (Coolify has built-in monitoring)
|
|
||||||
- Configure alerts for downtime
|
|
||||||
- Set up automated backups
|
|
||||||
- Consider CDN for static assets (if needed)
|
|
||||||
@@ -1,387 +0,0 @@
|
|||||||
# Docker Deployment Guide
|
|
||||||
|
|
||||||
This application is fully containerized and ready for Docker deployment.
|
|
||||||
|
|
||||||
## Quick Start with Docker Compose
|
|
||||||
|
|
||||||
The easiest way to run the entire stack locally:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the application and database
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Check if containers are running
|
|
||||||
docker-compose ps
|
|
||||||
|
|
||||||
# Run database migrations
|
|
||||||
docker-compose exec app bun run db:push
|
|
||||||
|
|
||||||
# View application logs
|
|
||||||
docker-compose logs -f app
|
|
||||||
|
|
||||||
# View database logs
|
|
||||||
docker-compose logs -f db
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit `http://localhost:3000`
|
|
||||||
|
|
||||||
### Stopping
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop containers
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Stop and remove volumes (⚠️ deletes data)
|
|
||||||
docker-compose down -v
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building the Docker Image
|
|
||||||
|
|
||||||
### Local Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build the image
|
|
||||||
docker build -t wishlist-app .
|
|
||||||
|
|
||||||
# Run the container (requires database)
|
|
||||||
docker run -d \
|
|
||||||
-p 3000:3000 \
|
|
||||||
-e DATABASE_URL="postgresql://user:pass@host:5432/db" \
|
|
||||||
--name wishlist-app \
|
|
||||||
wishlist-app
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-stage Build Details
|
|
||||||
|
|
||||||
The Dockerfile uses a multi-stage build for optimization:
|
|
||||||
|
|
||||||
1. **base**: Base Bun image
|
|
||||||
2. **deps**: Install dependencies with frozen lockfile
|
|
||||||
3. **builder**: Build the SvelteKit application
|
|
||||||
4. **runner**: Production image with minimal size
|
|
||||||
|
|
||||||
Final image includes:
|
|
||||||
- Built application (`/app/build`)
|
|
||||||
- Production dependencies
|
|
||||||
- Drizzle schema for migrations
|
|
||||||
- Port 3000 exposed
|
|
||||||
|
|
||||||
## Environment Variables
|
|
||||||
|
|
||||||
Required environment variables:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql://username:password@host:port/database
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### For docker-compose
|
|
||||||
|
|
||||||
Edit `docker-compose.yml` to change database credentials.
|
|
||||||
|
|
||||||
### For Coolify
|
|
||||||
|
|
||||||
Set in the Coolify dashboard under **Environment Variables**.
|
|
||||||
|
|
||||||
## Database Migrations
|
|
||||||
|
|
||||||
### Initial Setup
|
|
||||||
|
|
||||||
After first deployment:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using docker-compose
|
|
||||||
docker-compose exec app bun run db:push
|
|
||||||
|
|
||||||
# Using standalone container
|
|
||||||
docker exec -it wishlist-app bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
### Applying Schema Changes
|
|
||||||
|
|
||||||
After modifying `src/lib/server/schema.ts`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Generate migration
|
|
||||||
bun run db:generate
|
|
||||||
|
|
||||||
# Apply to running container
|
|
||||||
docker-compose exec app bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Port Configuration
|
|
||||||
|
|
||||||
Default port: `3000`
|
|
||||||
|
|
||||||
To change:
|
|
||||||
|
|
||||||
1. **docker-compose.yml**:
|
|
||||||
```yaml
|
|
||||||
ports:
|
|
||||||
- "8080:3000" # External:Internal
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Dockerfile** (if needed):
|
|
||||||
```dockerfile
|
|
||||||
ENV PORT=3000
|
|
||||||
EXPOSE 3000
|
|
||||||
```
|
|
||||||
|
|
||||||
## Volumes and Data Persistence
|
|
||||||
|
|
||||||
### PostgreSQL Data
|
|
||||||
|
|
||||||
Data is persisted in a Docker volume:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
To backup:
|
|
||||||
```bash
|
|
||||||
docker-compose exec db pg_dump -U wishlistuser wishlist > backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
To restore:
|
|
||||||
```bash
|
|
||||||
docker-compose exec -T db psql -U wishlistuser wishlist < backup.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
## Health Checks
|
|
||||||
|
|
||||||
### Application Health
|
|
||||||
|
|
||||||
The app responds on `http://localhost:3000/`
|
|
||||||
|
|
||||||
### Database Health
|
|
||||||
|
|
||||||
PostgreSQL health check is configured in docker-compose.yml:
|
|
||||||
```yaml
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U wishlistuser -d wishlist"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Considerations
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
1. **Change default credentials** in docker-compose.yml
|
|
||||||
2. **Use secrets** for sensitive data:
|
|
||||||
```yaml
|
|
||||||
secrets:
|
|
||||||
db_password:
|
|
||||||
file: ./secrets/db_password.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Don't expose PostgreSQL port** in production:
|
|
||||||
```yaml
|
|
||||||
# Remove or comment out:
|
|
||||||
# ports:
|
|
||||||
# - "5432:5432"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
1. **Resource Limits**:
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
cpus: '0.5'
|
|
||||||
memory: 512M
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Use PostgreSQL connection pooling** for high traffic
|
|
||||||
|
|
||||||
### Networking
|
|
||||||
|
|
||||||
For production with reverse proxy (Nginx, Traefik):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
networks:
|
|
||||||
- traefik_network
|
|
||||||
- internal
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.wishlist.rule=Host(`wishlist.example.com`)"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Container won't start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
docker-compose logs app
|
|
||||||
|
|
||||||
# Common issues:
|
|
||||||
# - DATABASE_URL incorrect
|
|
||||||
# - Database not ready
|
|
||||||
# - Port already in use
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database connection failed
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Test database connectivity
|
|
||||||
docker-compose exec app sh
|
|
||||||
bun run db:push
|
|
||||||
|
|
||||||
# Check database is running
|
|
||||||
docker-compose ps db
|
|
||||||
|
|
||||||
# Restart database
|
|
||||||
docker-compose restart db
|
|
||||||
```
|
|
||||||
|
|
||||||
### Build fails
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clear build cache
|
|
||||||
docker-compose build --no-cache
|
|
||||||
|
|
||||||
# Check Docker resources
|
|
||||||
docker system df
|
|
||||||
docker system prune # Clean up if needed
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permission errors
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Fix file permissions
|
|
||||||
sudo chown -R $USER:$USER .
|
|
||||||
|
|
||||||
# Rebuild
|
|
||||||
docker-compose up --build -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development with Docker
|
|
||||||
|
|
||||||
### Live Development
|
|
||||||
|
|
||||||
For development with hot reload, mount source:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
app:
|
|
||||||
command: bun run dev
|
|
||||||
volumes:
|
|
||||||
- ./src:/app/src
|
|
||||||
- ./static:/app/static
|
|
||||||
environment:
|
|
||||||
NODE_ENV: development
|
|
||||||
```
|
|
||||||
|
|
||||||
### Access Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using psql
|
|
||||||
docker-compose exec db psql -U wishlistuser wishlist
|
|
||||||
|
|
||||||
# Using Drizzle Studio
|
|
||||||
docker-compose exec app bun run db:studio
|
|
||||||
```
|
|
||||||
|
|
||||||
## CI/CD Integration
|
|
||||||
|
|
||||||
### GitHub Actions Example
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: Build and Push Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Build image
|
|
||||||
run: docker build -t wishlist-app .
|
|
||||||
- name: Push to registry
|
|
||||||
run: |
|
|
||||||
docker tag wishlist-app registry.example.com/wishlist-app
|
|
||||||
docker push registry.example.com/wishlist-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coolify Deployment
|
|
||||||
|
|
||||||
For Coolify deployment, see [COOLIFY_DEPLOYMENT.md](./COOLIFY_DEPLOYMENT.md)
|
|
||||||
|
|
||||||
Coolify will:
|
|
||||||
1. Pull from your Git repository
|
|
||||||
2. Build using this Dockerfile
|
|
||||||
3. Deploy with configured environment variables
|
|
||||||
4. Set up networking and SSL automatically
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Container Stats
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Real-time stats
|
|
||||||
docker stats wishlist-app wishlist-db
|
|
||||||
|
|
||||||
# Resource usage
|
|
||||||
docker-compose top
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Follow logs
|
|
||||||
docker-compose logs -f
|
|
||||||
|
|
||||||
# Last 100 lines
|
|
||||||
docker-compose logs --tail=100
|
|
||||||
|
|
||||||
# Specific service
|
|
||||||
docker-compose logs -f app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cleanup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Stop and remove containers
|
|
||||||
docker-compose down
|
|
||||||
|
|
||||||
# Remove images
|
|
||||||
docker rmi wishlist-app
|
|
||||||
|
|
||||||
# Remove all unused data
|
|
||||||
docker system prune -a
|
|
||||||
|
|
||||||
# Remove specific volume
|
|
||||||
docker volume rm wishlist-app_postgres_data
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. ✅ Use `.dockerignore` to reduce build context
|
|
||||||
2. ✅ Multi-stage builds for smaller images
|
|
||||||
3. ✅ Non-root user in production
|
|
||||||
4. ✅ Health checks configured
|
|
||||||
5. ✅ Secrets management for credentials
|
|
||||||
6. ✅ Resource limits defined
|
|
||||||
7. ✅ Regular backups of database
|
|
||||||
8. ✅ Monitoring and logging
|
|
||||||
9. ✅ Security scanning of images
|
|
||||||
10. ✅ Version tags for images
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- [Docker Documentation](https://docs.docker.com/)
|
|
||||||
- [Docker Compose Documentation](https://docs.docker.com/compose/)
|
|
||||||
- [Coolify Documentation](https://coolify.io/docs)
|
|
||||||
- [SvelteKit Deployment](https://kit.svelte.dev/docs/adapters)
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
# Wishlist App
|
|
||||||
|
|
||||||
A simple, self-contained wishlist application built with SvelteKit, Tailwind CSS, Drizzle ORM, and PostgreSQL. Create wishlists and share them with friends and family via secure links.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- 🎁 Create wishlists with items (title, description, links, images, prices, priorities)
|
|
||||||
- 🔗 Two types of links:
|
|
||||||
- **Owner Link**: Edit and manage your wishlist
|
|
||||||
- **Public Link**: Share with friends to view and reserve items
|
|
||||||
- 🔒 Link-based security (no accounts required)
|
|
||||||
- 👥 Reserve items with optional name
|
|
||||||
- 📱 Fully responsive mobile design
|
|
||||||
- 🎨 Clean, modern UI with shadcn components
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Framework**: SvelteKit 2 with Svelte 5
|
|
||||||
- **Styling**: Tailwind CSS v4
|
|
||||||
- **Database**: PostgreSQL with Drizzle ORM
|
|
||||||
- **UI Components**: shadcn-svelte
|
|
||||||
- **Runtime**: Bun
|
|
||||||
- **TypeScript**: Full type safety
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 🐳 Docker (Recommended for Quick Testing)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
docker-compose exec app bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit `http://localhost:3000` 🎉
|
|
||||||
|
|
||||||
See [DOCKER.md](./DOCKER.md) for complete Docker documentation.
|
|
||||||
|
|
||||||
### 📚 Deployment Guides
|
|
||||||
|
|
||||||
- **[COOLIFY_DEPLOYMENT.md](./COOLIFY_DEPLOYMENT.md)** - Deploy to Coolify (recommended for production)
|
|
||||||
- **[DOCKER.md](./DOCKER.md)** - Docker & docker-compose guide
|
|
||||||
- **[SETUP.md](./SETUP.md)** - Local development setup
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- [Bun](https://bun.sh/) installed (for local development)
|
|
||||||
- PostgreSQL database (local, Docker, or hosted)
|
|
||||||
- Docker (optional, for containerized deployment)
|
|
||||||
|
|
||||||
## Getting Started (Local Development)
|
|
||||||
|
|
||||||
### 1. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Set Up Environment Variables
|
|
||||||
|
|
||||||
Create a `.env` file in the root directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env` and add your PostgreSQL connection string:
|
|
||||||
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql://username:password@localhost:5432/wishlist
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Set Up the Database
|
|
||||||
|
|
||||||
Push the database schema (easiest for development):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use migrations (recommended for production):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run db:generate
|
|
||||||
bun run db:migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Start the Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The app will be available at `http://localhost:5173`
|
|
||||||
|
|
||||||
## Database Commands
|
|
||||||
|
|
||||||
- `bun run db:generate` - Generate new migration from schema changes
|
|
||||||
- `bun run db:migrate` - Run migrations
|
|
||||||
- `bun run db:push` - Push schema directly to database (development)
|
|
||||||
- `bun run db:studio` - Open Drizzle Studio to browse your database
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Creating a Wishlist
|
|
||||||
|
|
||||||
1. Visit the home page
|
|
||||||
2. Enter a title and optional description
|
|
||||||
3. Click "Create Wishlist"
|
|
||||||
4. You'll be redirected to the owner edit page with your unique owner link
|
|
||||||
|
|
||||||
### Managing Your Wishlist (Owner)
|
|
||||||
|
|
||||||
- Add items with details (name, description, URL, image, price, priority)
|
|
||||||
- Delete items
|
|
||||||
- See which items have been reserved (but not who reserved them for surprise protection)
|
|
||||||
- Share the public link with friends and family
|
|
||||||
- Keep your owner link private to maintain edit access
|
|
||||||
|
|
||||||
### Viewing and Reserving Items (Public)
|
|
||||||
|
|
||||||
- Open the public link shared by the wishlist owner
|
|
||||||
- Browse available items
|
|
||||||
- Click "Reserve This" on any item to claim it
|
|
||||||
- Optionally add your name so others can coordinate
|
|
||||||
- Cancel your reservation if plans change
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── lib/
|
|
||||||
│ ├── components/ui/ # shadcn-svelte components
|
|
||||||
│ ├── server/
|
|
||||||
│ │ ├── db.ts # Database connection
|
|
||||||
│ │ └── schema.ts # Drizzle schema definitions
|
|
||||||
│ └── utils.ts # Utility functions
|
|
||||||
├── routes/
|
|
||||||
│ ├── api/wishlists/ # API endpoints
|
|
||||||
│ ├── wishlist/[token]/ # Public view page
|
|
||||||
│ │ └── edit/ # Owner edit page
|
|
||||||
│ └── +page.svelte # Home page
|
|
||||||
└── app.css # Global styles with Tailwind
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### wishlists
|
|
||||||
- `id` - UUID primary key
|
|
||||||
- `title` - Wishlist title
|
|
||||||
- `description` - Optional description
|
|
||||||
- `ownerToken` - Unique token for editing
|
|
||||||
- `publicToken` - Unique token for viewing
|
|
||||||
- `createdAt`, `updatedAt` - Timestamps
|
|
||||||
|
|
||||||
### items
|
|
||||||
- `id` - UUID primary key
|
|
||||||
- `wishlistId` - Foreign key to wishlists
|
|
||||||
- `title` - Item name
|
|
||||||
- `description` - Optional description
|
|
||||||
- `link` - Optional product URL
|
|
||||||
- `imageUrl` - Optional image URL
|
|
||||||
- `price` - Optional price
|
|
||||||
- `priority` - high | medium | low
|
|
||||||
- `isReserved` - Boolean flag
|
|
||||||
- `createdAt`, `updatedAt` - Timestamps
|
|
||||||
|
|
||||||
### reservations
|
|
||||||
- `id` - UUID primary key
|
|
||||||
- `itemId` - Foreign key to items
|
|
||||||
- `reserverName` - Optional name of person who reserved
|
|
||||||
- `createdAt` - Timestamp
|
|
||||||
|
|
||||||
## Security Considerations
|
|
||||||
|
|
||||||
- Links use cryptographically secure random IDs (cuid2)
|
|
||||||
- Owner and public tokens are separate and unique
|
|
||||||
- No authentication system means links should be treated as passwords
|
|
||||||
- Owner links should be kept private
|
|
||||||
- Public links can be shared freely
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
### Coolify (Docker) - Recommended
|
|
||||||
|
|
||||||
This application includes a Dockerfile optimized for Coolify deployment.
|
|
||||||
|
|
||||||
📖 **See [COOLIFY_DEPLOYMENT.md](./COOLIFY_DEPLOYMENT.md) for complete deployment guide**
|
|
||||||
|
|
||||||
Quick steps:
|
|
||||||
1. Push code to Git repository
|
|
||||||
2. Create PostgreSQL database in Coolify
|
|
||||||
3. Create new application in Coolify
|
|
||||||
4. Set `DATABASE_URL` environment variable
|
|
||||||
5. Deploy and run `bun run db:push` to set up schema
|
|
||||||
|
|
||||||
### Build for Production
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Preview Production Build
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run preview
|
|
||||||
```
|
|
||||||
|
|
||||||
### Other Platforms
|
|
||||||
|
|
||||||
This app uses `@sveltejs/adapter-node` for Docker/Node.js deployments, but can be adapted for:
|
|
||||||
|
|
||||||
- **Vercel/Netlify**: Change to `@sveltejs/adapter-auto`
|
|
||||||
- **Cloudflare Pages**: Use `@sveltejs/adapter-cloudflare`
|
|
||||||
- **Static**: Use `@sveltejs/adapter-static` (requires API route adjustments)
|
|
||||||
|
|
||||||
Make sure to set your `DATABASE_URL` environment variable in your deployment platform.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
- All components are fully typed with TypeScript
|
|
||||||
- UI components follow shadcn-svelte patterns
|
|
||||||
- Mobile-first responsive design using Tailwind
|
|
||||||
- Server-side rendering for better performance and SEO
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# Quick Setup Guide
|
|
||||||
|
|
||||||
Follow these steps to get your wishlist app running:
|
|
||||||
|
|
||||||
## Choose Your Setup Method
|
|
||||||
|
|
||||||
### Method 1: Docker Compose (Easiest) 🐳
|
|
||||||
|
|
||||||
If you have Docker installed, this is the fastest way to get started:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start everything with Docker
|
|
||||||
docker-compose up -d
|
|
||||||
|
|
||||||
# Run database migrations
|
|
||||||
docker-compose exec app bun run db:push
|
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker-compose logs -f app
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit `http://localhost:3000` 🎉
|
|
||||||
|
|
||||||
To stop:
|
|
||||||
```bash
|
|
||||||
docker-compose down
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Method 2: Local Development (Traditional)
|
|
||||||
|
|
||||||
## 1. Prerequisites
|
|
||||||
|
|
||||||
Make sure you have:
|
|
||||||
- ✅ Bun installed (already done!)
|
|
||||||
- ✅ PostgreSQL database running
|
|
||||||
|
|
||||||
### Setting up PostgreSQL (if needed)
|
|
||||||
|
|
||||||
#### Option A: Local PostgreSQL
|
|
||||||
```bash
|
|
||||||
# Install PostgreSQL (Ubuntu/Debian)
|
|
||||||
sudo apt install postgresql postgresql-contrib
|
|
||||||
|
|
||||||
# Start PostgreSQL
|
|
||||||
sudo systemctl start postgresql
|
|
||||||
|
|
||||||
# Create a database
|
|
||||||
sudo -u postgres createdb wishlist
|
|
||||||
|
|
||||||
# Create a user and grant permissions
|
|
||||||
sudo -u postgres psql
|
|
||||||
CREATE USER wishlistuser WITH PASSWORD 'yourpassword';
|
|
||||||
GRANT ALL PRIVILEGES ON DATABASE wishlist TO wishlistuser;
|
|
||||||
\q
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option B: Docker PostgreSQL
|
|
||||||
```bash
|
|
||||||
docker run --name wishlist-postgres \
|
|
||||||
-e POSTGRES_DB=wishlist \
|
|
||||||
-e POSTGRES_USER=wishlistuser \
|
|
||||||
-e POSTGRES_PASSWORD=yourpassword \
|
|
||||||
-p 5432:5432 \
|
|
||||||
-d postgres:16
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Option C: Hosted Database
|
|
||||||
- [Supabase](https://supabase.com/) - Free tier available
|
|
||||||
- [Neon](https://neon.tech/) - Serverless PostgreSQL
|
|
||||||
- [Railway](https://railway.app/) - Easy deployment
|
|
||||||
|
|
||||||
## 2. Configure Environment
|
|
||||||
|
|
||||||
Create `.env` file:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit `.env` with your database connection:
|
|
||||||
```env
|
|
||||||
DATABASE_URL=postgresql://wishlistuser:yourpassword@localhost:5432/wishlist
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Setup Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Push the schema to your database
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Start Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit `http://localhost:5173` 🎉
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Database Connection Issues
|
|
||||||
|
|
||||||
If you get connection errors:
|
|
||||||
|
|
||||||
1. Check PostgreSQL is running:
|
|
||||||
```bash
|
|
||||||
# Linux/Mac
|
|
||||||
sudo systemctl status postgresql
|
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker ps | grep postgres
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Verify connection string format:
|
|
||||||
```
|
|
||||||
postgresql://username:password@host:port/database
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Test connection:
|
|
||||||
```bash
|
|
||||||
psql "postgresql://wishlistuser:yourpassword@localhost:5432/wishlist"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Port Already in Use
|
|
||||||
|
|
||||||
If port 5173 is taken:
|
|
||||||
```bash
|
|
||||||
bun run dev -- --port 3000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Schema Changes
|
|
||||||
|
|
||||||
After modifying `src/lib/server/schema.ts`:
|
|
||||||
```bash
|
|
||||||
bun run db:push
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Create your first wishlist
|
|
||||||
2. Add some items
|
|
||||||
3. Share the public link with friends
|
|
||||||
4. Save your owner link somewhere safe!
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
See the main README.md for deployment instructions.
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
CREATE TABLE "items" (
|
|
||||||
"id" uuid PRIMARY KEY NOT NULL,
|
|
||||||
"wishlist_id" uuid NOT NULL,
|
|
||||||
"title" text NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"link" text,
|
|
||||||
"image_url" text,
|
|
||||||
"price" numeric(10, 2),
|
|
||||||
"priority" text DEFAULT 'medium',
|
|
||||||
"is_reserved" boolean DEFAULT false NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "reservations" (
|
|
||||||
"id" uuid PRIMARY KEY NOT NULL,
|
|
||||||
"item_id" uuid NOT NULL,
|
|
||||||
"reserver_name" text,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE "wishlists" (
|
|
||||||
"id" uuid PRIMARY KEY NOT NULL,
|
|
||||||
"title" text NOT NULL,
|
|
||||||
"description" text,
|
|
||||||
"owner_token" text NOT NULL,
|
|
||||||
"public_token" text NOT NULL,
|
|
||||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
"updated_at" timestamp DEFAULT now() NOT NULL,
|
|
||||||
CONSTRAINT "wishlists_owner_token_unique" UNIQUE("owner_token"),
|
|
||||||
CONSTRAINT "wishlists_public_token_unique" UNIQUE("public_token")
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE "items" ADD CONSTRAINT "items_wishlist_id_wishlists_id_fk" FOREIGN KEY ("wishlist_id") REFERENCES "public"."wishlists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
||||||
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_item_id_items_id_fk" FOREIGN KEY ("item_id") REFERENCES "public"."items"("id") ON DELETE cascade ON UPDATE no action;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "7",
|
|
||||||
"dialect": "postgresql",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"idx": 0,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1763822218153,
|
|
||||||
"tag": "0000_last_steve_rogers",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-background: hsl(0 0% 100%);
|
|
||||||
--color-foreground: hsl(222.2 84% 4.9%);
|
|
||||||
--color-card: hsl(0 0% 100%);
|
|
||||||
--color-card-foreground: hsl(222.2 84% 4.9%);
|
|
||||||
--color-popover: hsl(0 0% 100%);
|
|
||||||
--color-popover-foreground: hsl(222.2 84% 4.9%);
|
|
||||||
--color-primary: hsl(222.2 47.4% 11.2%);
|
|
||||||
--color-primary-foreground: hsl(210 40% 98%);
|
|
||||||
--color-secondary: hsl(210 40% 96.1%);
|
|
||||||
--color-secondary-foreground: hsl(222.2 47.4% 11.2%);
|
|
||||||
--color-muted: hsl(210 40% 96.1%);
|
|
||||||
--color-muted-foreground: hsl(215.4 16.3% 46.9%);
|
|
||||||
--color-accent: hsl(210 40% 96.1%);
|
|
||||||
--color-accent-foreground: hsl(222.2 47.4% 11.2%);
|
|
||||||
--color-destructive: hsl(0 84.2% 60.2%);
|
|
||||||
--color-destructive-foreground: hsl(210 40% 98%);
|
|
||||||
--color-border: hsl(214.3 31.8% 91.4%);
|
|
||||||
--color-input: hsl(214.3 31.8% 91.4%);
|
|
||||||
--color-ring: hsl(222.2 84% 4.9%);
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
border-color: hsl(var(--color-border));
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
background-color: hsl(var(--color-background));
|
|
||||||
color: hsl(var(--color-foreground));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
wishlist-app/src/app.d.ts
vendored
13
wishlist-app/src/app.d.ts
vendored
@@ -1,13 +0,0 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
// interface Error {}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user