trips) adding initial commit
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.svelte-kit
|
||||||
|
build
|
||||||
|
.env
|
||||||
|
*.spk
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
.DS_Store
|
||||||
|
.git
|
||||||
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Synology SSO (OIDC) — values come from DSM > SSO Server > Application
|
||||||
|
SYNOLOGY_ISSUER=https://cloud.campbellwireless.net/auth/webman/sso
|
||||||
|
PROTOCOL_HEADER=x-forwarded-proto
|
||||||
|
HOST_HEADER=x-forwarded-host
|
||||||
|
SYNOLOGY_CLIENT_ID=
|
||||||
|
SYNOLOGY_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Auth.js — set the full public URL so callbacks use https
|
||||||
|
AUTH_URL=https://cloud.campbellwireless.net/trips/auth
|
||||||
|
# Auth.js secret — generate with: openssl rand -base64 32
|
||||||
|
AUTH_SECRET=
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# SQLite (default): file:trips.db
|
||||||
|
# Postgres (future): postgresql://user:password@host:5432/trips
|
||||||
|
DATABASE_URL=file:trips.db
|
||||||
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
# Build artefacts
|
||||||
|
*.spk
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
15
.prettierrc
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# ── Build stage ────────────────────────────────────────────────────────────────
|
||||||
|
FROM oven/bun:1 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency manifests first for better layer caching
|
||||||
|
COPY package.json bun.lock ./
|
||||||
|
|
||||||
|
# Install all deps (including devDependencies needed for the build).
|
||||||
|
# better-sqlite3 is a native module — it must be compiled here on Linux,
|
||||||
|
# not copied from a macOS node_modules.
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
# Prune to production-only deps, recompiling native modules for Linux
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
# ── Runtime stage ───────────────────────────────────────────────────────────────
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create a non-root user to run the app
|
||||||
|
RUN addgroup -S trips && adduser -S trips -G trips
|
||||||
|
|
||||||
|
# Copy the built app and production node_modules from the builder
|
||||||
|
COPY --from=builder /app/build ./build
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Directory for the SQLite database (mount a volume here)
|
||||||
|
RUN mkdir -p /data && chown trips:trips /data
|
||||||
|
|
||||||
|
USER trips
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATABASE_URL=file:/data/trips.db
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "build"]
|
||||||
35
README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
447
bun.lock
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "trips",
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/sveltekit": "^1.11.1",
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.3",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"svelte": "^5.49.2",
|
||||||
|
"svelte-check": "^4.3.6",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@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/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=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="],
|
||||||
|
|
||||||
|
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||||
|
|
||||||
|
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs": ["@rollup/plugin-commonjs@29.0.0", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", "fdir": "^6.2.0", "is-reference": "1.2.1", "magic-string": "^0.30.3", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-U2YHaxR2cU/yAiwKJtJRhnyLk7cifnQw0zUpISsocBDoHDJn+HTV74ABqnwr5bEgWUwFZC9oFL6wLe21lHu5eQ=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-json": ["@rollup/plugin-json@6.1.0", "", { "dependencies": { "@rollup/pluginutils": "^5.1.0" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-node-resolve": ["@rollup/plugin-node-resolve@16.0.3", "", { "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", "is-module": "^1.0.0", "resolve": "^1.22.1" }, "peerDependencies": { "rollup": "^2.78.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg=="],
|
||||||
|
|
||||||
|
"@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||||
|
|
||||||
|
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
|
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.9", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
|
||||||
|
|
||||||
|
"@sveltejs/adapter-node": ["@sveltejs/adapter-node@5.5.3", "", { "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "sha512-yeWbKXBL9vqDb/7R8ebvRHeuBHN4cRYYBSquNJSMQtS6rIYkXxsVSveaMTUaLvHYQsb1zNa+nH2iLTOMawBohA=="],
|
||||||
|
|
||||||
|
"@sveltejs/kit": ["@sveltejs/kit@2.52.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.6.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^3.0.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-zG+HmJuSF7eC0e7xt2htlOcEMAdEtlVdb7+gAr+ef08EhtwUsjLxcAwBgUCJY3/5p08OVOxVZti91WfXeuLvsg=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="],
|
||||||
|
|
||||||
|
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.2", "", { "dependencies": { "obug": "^2.1.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" } }, "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", "@tailwindcss/oxide-darwin-x64": "4.2.0", "@tailwindcss/oxide-freebsd-x64": "4.2.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", "@tailwindcss/oxide-linux-x64-musl": "4.2.0", "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" } }, "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.0", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.0", "", { "dependencies": { "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "tailwindcss": "4.2.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA=="],
|
||||||
|
|
||||||
|
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
|
||||||
|
|
||||||
|
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
|
||||||
|
|
||||||
|
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||||
|
|
||||||
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||||
|
|
||||||
|
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||||
|
|
||||||
|
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||||
|
|
||||||
|
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||||
|
|
||||||
|
"better-sqlite3": ["better-sqlite3@12.6.2", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA=="],
|
||||||
|
|
||||||
|
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||||
|
|
||||||
|
"bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||||
|
|
||||||
|
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||||
|
|
||||||
|
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||||
|
|
||||||
|
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
|
||||||
|
|
||||||
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
"commondir": ["commondir@1.0.1", "", {}, "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg=="],
|
||||||
|
|
||||||
|
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||||
|
|
||||||
|
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
|
||||||
|
|
||||||
|
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="],
|
||||||
|
|
||||||
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||||
|
|
||||||
|
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||||
|
|
||||||
|
"esrap": ["esrap@2.2.3", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ=="],
|
||||||
|
|
||||||
|
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||||
|
|
||||||
|
"expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
|
||||||
|
|
||||||
|
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
|
||||||
|
|
||||||
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||||
|
|
||||||
|
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||||
|
|
||||||
|
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||||
|
|
||||||
|
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||||
|
|
||||||
|
"is-module": ["is-module@1.0.0", "", {}, "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g=="],
|
||||||
|
|
||||||
|
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||||
|
|
||||||
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||||
|
|
||||||
|
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||||
|
|
||||||
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
|
||||||
|
|
||||||
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||||
|
|
||||||
|
"node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="],
|
||||||
|
|
||||||
|
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
|
||||||
|
|
||||||
|
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||||
|
|
||||||
|
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||||
|
|
||||||
|
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
||||||
|
|
||||||
|
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
|
||||||
|
|
||||||
|
"prettier-plugin-svelte": ["prettier-plugin-svelte@3.4.1", "", { "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg=="],
|
||||||
|
|
||||||
|
"prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="],
|
||||||
|
|
||||||
|
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
|
||||||
|
|
||||||
|
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
|
||||||
|
|
||||||
|
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
|
"set-cookie-parser": ["set-cookie-parser@3.0.1", "", {}, "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q=="],
|
||||||
|
|
||||||
|
"simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="],
|
||||||
|
|
||||||
|
"simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||||
|
|
||||||
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"svelte": ["svelte@5.51.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.6.2", "esm-env": "^1.2.1", "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-3+ni7BMjiEQeMCa1fDQzHy2ESAebgQDVOTuE4jlj2/QOAB2grRta8ew80p95miWE+ZmimpL7B3t9SSO4rv0aqQ=="],
|
||||||
|
|
||||||
|
"svelte-check": ["svelte-check@4.4.0", "", { "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-gB3FdEPb8tPO3Y7Dzc6d/Pm/KrXAhK+0Fk+LkcysVtupvAh6Y/IrBCEZNupq57oh0hcwlxCUamu/rq7GtvfSEg=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
|
||||||
|
|
||||||
|
"tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
|
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||||
|
|
||||||
|
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
|
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.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-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||||
|
|
||||||
|
"@auth/sveltekit/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||||
|
|
||||||
|
"@rollup/plugin-commonjs/is-reference": ["is-reference@1.2.1", "", { "dependencies": { "@types/estree": "*" } }, "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
services:
|
||||||
|
trips:
|
||||||
|
build: .
|
||||||
|
container_name: trips
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
# Persistent SQLite database
|
||||||
|
- trips-data:/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: file:/data/trips.db
|
||||||
|
PORT: "3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
trips-data:
|
||||||
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "trips",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-node": "^5.5.3",
|
||||||
|
"@sveltejs/kit": "^2.50.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.0",
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"svelte": "^5.49.2",
|
||||||
|
"svelte-check": "^4.3.6",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@auth/sveltekit": "^1.11.1",
|
||||||
|
"better-sqlite3": "^12.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
scripts/build-spk.sh
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Builds the Synology SPK launcher package for the Trips app.
|
||||||
|
# Usage: bash scripts/build-spk.sh [output-name]
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
SPK_DIR="$REPO_ROOT/spk"
|
||||||
|
OUT="${1:-$REPO_ROOT/trips.spk}"
|
||||||
|
|
||||||
|
cd "$SPK_DIR"
|
||||||
|
|
||||||
|
echo "Building SPK from $SPK_DIR..."
|
||||||
|
|
||||||
|
# package.tgz is the payload — it must be a gzip-compressed tar.
|
||||||
|
# It can be empty but must exist and be valid.
|
||||||
|
touch .keep
|
||||||
|
tar czf package.tgz .keep
|
||||||
|
rm .keep
|
||||||
|
|
||||||
|
# The outer SPK wrapper is a plain (uncompressed) tar — NOT gzipped.
|
||||||
|
# DSM unpacks it with tar, not gunzip+tar.
|
||||||
|
tar cf "$OUT" \
|
||||||
|
INFO \
|
||||||
|
PACKAGE_ICON.PNG \
|
||||||
|
PACKAGE_ICON_256.PNG \
|
||||||
|
package.tgz \
|
||||||
|
scripts \
|
||||||
|
conf
|
||||||
|
|
||||||
|
rm -f package.tgz
|
||||||
|
|
||||||
|
echo "Built: $OUT"
|
||||||
1
src/app.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
17
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { DefaultSession } from '@auth/sveltekit';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
interface Locals {
|
||||||
|
auth(): Promise<DefaultSession | null>;
|
||||||
|
}
|
||||||
|
interface PageData {
|
||||||
|
session: DefaultSession | null;
|
||||||
|
}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
12
src/app.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" style="background-color: #f9fafb">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="/trips/favicon.svg" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
44
src/auth.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { SvelteKitAuth } from '@auth/sveltekit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
|
||||||
|
export const { handle, signIn, signOut } = SvelteKitAuth({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'synology',
|
||||||
|
name: 'Synology',
|
||||||
|
type: 'oidc',
|
||||||
|
issuer: env.SYNOLOGY_ISSUER,
|
||||||
|
clientId: env.SYNOLOGY_CLIENT_ID,
|
||||||
|
clientSecret: env.SYNOLOGY_CLIENT_SECRET,
|
||||||
|
redirectProxyUrl: env.AUTH_URL,
|
||||||
|
profile(profile) {
|
||||||
|
return {
|
||||||
|
id: profile.sub as string,
|
||||||
|
name: (profile.username ?? profile.name ?? profile.preferred_username) as string,
|
||||||
|
email: profile.email as string | undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
trustHost: true,
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, profile }) {
|
||||||
|
if (profile?.sub) token.sub = profile.sub as string;
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (token.sub) session.user.id = token.sub;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
redirect({ url, baseUrl }) {
|
||||||
|
const appRoot = env.AUTH_URL?.replace(/\/auth$/, '') ?? baseUrl;
|
||||||
|
if (url.startsWith(appRoot)) return url;
|
||||||
|
return appRoot;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cookies: {
|
||||||
|
sessionToken: { name: 'authjs.session-token' },
|
||||||
|
callbackUrl: { name: 'authjs.callback-url' },
|
||||||
|
csrfToken: { name: 'authjs.csrf-token' }
|
||||||
|
}
|
||||||
|
});
|
||||||
15
src/hooks.server.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { handle as authHandle } from './auth.js';
|
||||||
|
import { sequence } from '@sveltejs/kit/hooks';
|
||||||
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
// When behind a reverse proxy that terminates SSL, rewrite the request URL
|
||||||
|
// to use https so that SvelteKit generates correct absolute URLs and form actions.
|
||||||
|
const httpsProxy: Handle = async ({ event, resolve }) => {
|
||||||
|
const proto = event.request.headers.get('x-forwarded-proto');
|
||||||
|
if (proto === 'https' && event.url.protocol === 'http:') {
|
||||||
|
event.url.protocol = 'https:';
|
||||||
|
}
|
||||||
|
return resolve(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handle = sequence(httpsProxy, authHandle);
|
||||||
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
252
src/lib/components/AddDestinationModal.svelte
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
|
||||||
|
interface City {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
country_code: string;
|
||||||
|
population: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onclose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onclose }: Props = $props();
|
||||||
|
|
||||||
|
let query = $state('');
|
||||||
|
let cities = $state<City[]>([]);
|
||||||
|
let selected = $state<City | null>(null);
|
||||||
|
let loading = $state(false);
|
||||||
|
let startDate = $state('');
|
||||||
|
let endDate = $state('');
|
||||||
|
let startUnknown = $state(false);
|
||||||
|
let endUnknown = $state(false);
|
||||||
|
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
function flagEmoji(code: string): string {
|
||||||
|
return code
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueryInput() {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
selected = null;
|
||||||
|
if (!query.trim()) {
|
||||||
|
cities = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${base}/api/cities?q=${encodeURIComponent(query)}`);
|
||||||
|
cities = await res.json();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCity(city: City) {
|
||||||
|
selected = city;
|
||||||
|
query = `${city.name}, ${city.country}`;
|
||||||
|
cities = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
query = '';
|
||||||
|
cities = [];
|
||||||
|
selected = null;
|
||||||
|
startDate = '';
|
||||||
|
endDate = '';
|
||||||
|
startUnknown = false;
|
||||||
|
endUnknown = false;
|
||||||
|
status = 'idea';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
reset();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-black/30"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={handleClose}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Panel -->
|
||||||
|
<div
|
||||||
|
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Add destination"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900">Add destination</h2>
|
||||||
|
<button
|
||||||
|
onclick={handleClose}
|
||||||
|
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addDestination"
|
||||||
|
class="flex flex-1 flex-col overflow-y-auto"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') handleClose();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
|
||||||
|
<!-- City search -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="city-search" class="text-sm font-medium text-gray-700">
|
||||||
|
City <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="city-search"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
bind:value={query}
|
||||||
|
oninput={onQueryInput}
|
||||||
|
placeholder="Search for a city..."
|
||||||
|
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
{#if loading}
|
||||||
|
<div class="absolute top-2.5 right-3 text-gray-400">
|
||||||
|
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if cities.length > 0}
|
||||||
|
<ul class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
|
||||||
|
{#each cities as city}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => selectCity(city)}
|
||||||
|
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span class="text-lg leading-none">{flagEmoji(city.country_code)}</span>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900">{city.name}</span>
|
||||||
|
<span class="ml-1.5 text-gray-500">{city.country}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden fields for selected city -->
|
||||||
|
{#if selected}
|
||||||
|
<input type="hidden" name="city_id" value={selected.id} />
|
||||||
|
<input type="hidden" name="city_name" value={selected.name} />
|
||||||
|
<input type="hidden" name="country" value={selected.country} />
|
||||||
|
<input type="hidden" name="country_code" value={selected.country_code} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dates -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="start_date" class="text-sm font-medium text-gray-700">Arrival</label>
|
||||||
|
<input
|
||||||
|
id="start_date"
|
||||||
|
name="start_date"
|
||||||
|
type="date"
|
||||||
|
bind:value={startDate}
|
||||||
|
disabled={startUnknown}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<input type="checkbox" bind:checked={startUnknown} onchange={() => startUnknown && (startDate = '')} />
|
||||||
|
I don't know yet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="end_date" class="text-sm font-medium text-gray-700">Departure</label>
|
||||||
|
<input
|
||||||
|
id="end_date"
|
||||||
|
name="end_date"
|
||||||
|
type="date"
|
||||||
|
bind:value={endDate}
|
||||||
|
disabled={endUnknown}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<input type="checkbox" bind:checked={endUnknown} onchange={() => endUnknown && (endDate = '')} />
|
||||||
|
I don't know yet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<span class="text-sm font-medium text-gray-700">Status</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each [
|
||||||
|
{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' },
|
||||||
|
{ value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' },
|
||||||
|
{ value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }
|
||||||
|
] as opt}
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all {status === opt.value ? opt.color + ' ring-2 ring-offset-1 ' + (opt.value === 'idea' ? 'ring-gray-400' : opt.value === 'tentative' ? 'ring-yellow-400' : 'ring-green-500') : 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
|
||||||
|
>
|
||||||
|
<input type="radio" name="status" value={opt.value} bind:group={status} class="sr-only" />
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleClose}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!selected}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add to trip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
283
src/lib/components/AddTravellerModal.svelte
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { Person } from '$lib/server/travellers.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onclose: () => void;
|
||||||
|
people: Person[]; // all people the user has created
|
||||||
|
tripTravellerIds: string[]; // person IDs already on this trip
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, onclose, people, tripTravellerIds }: Props = $props();
|
||||||
|
|
||||||
|
// People not yet on this trip
|
||||||
|
let available = $derived((people ?? []).filter((p) => !(tripTravellerIds ?? []).includes(p.id)));
|
||||||
|
let hasExisting = $derived(available.length > 0);
|
||||||
|
|
||||||
|
let showNewForm = $state(false);
|
||||||
|
let firstName = $state('');
|
||||||
|
let lastName = $state('');
|
||||||
|
let email = $state('');
|
||||||
|
|
||||||
|
// Show the new form immediately if there are no existing people to pick from
|
||||||
|
$effect(() => {
|
||||||
|
if (open && !hasExisting) showNewForm = true;
|
||||||
|
if (!open) showNewForm = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
function resetNew() {
|
||||||
|
firstName = '';
|
||||||
|
lastName = '';
|
||||||
|
email = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
resetNew();
|
||||||
|
onclose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagInitials(p: Person) {
|
||||||
|
return `${p.first_name[0] ?? ''}${p.last_name[0] ?? ''}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
const colours = [
|
||||||
|
'bg-blue-100 text-blue-700',
|
||||||
|
'bg-violet-100 text-violet-700',
|
||||||
|
'bg-pink-100 text-pink-700',
|
||||||
|
'bg-amber-100 text-amber-700',
|
||||||
|
'bg-teal-100 text-teal-700',
|
||||||
|
'bg-orange-100 text-orange-700',
|
||||||
|
'bg-indigo-100 text-indigo-700',
|
||||||
|
'bg-green-100 text-green-700'
|
||||||
|
];
|
||||||
|
|
||||||
|
function colour(p: Person) {
|
||||||
|
return colours[(p.first_name.charCodeAt(0) + p.last_name.charCodeAt(0)) % colours.length];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-black/30"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={handleClose}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && handleClose()}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Panel -->
|
||||||
|
<div
|
||||||
|
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Add traveller"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
<h2 class="text-base font-semibold text-gray-900">Add traveller</h2>
|
||||||
|
<button
|
||||||
|
onclick={handleClose}
|
||||||
|
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 flex-col overflow-y-auto">
|
||||||
|
<!-- Existing people picker -->
|
||||||
|
{#if hasExisting}
|
||||||
|
<div class="px-6 pt-5">
|
||||||
|
<p class="mb-3 text-xs font-semibold tracking-wider text-gray-400 uppercase">
|
||||||
|
Your travellers
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#each available as person (person.id)}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addTraveller"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') handleClose();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="person_id" value={person.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold {colour(
|
||||||
|
person
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{flagInitials(person)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{person.first_name}
|
||||||
|
{person.last_name}
|
||||||
|
{#if person.is_self}
|
||||||
|
<span
|
||||||
|
class="ml-1.5 rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700"
|
||||||
|
>You</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{#if person.email}
|
||||||
|
<p class="truncate text-xs text-gray-400">{person.email}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#9CA3AF"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider + toggle -->
|
||||||
|
<div class="px-6 pt-4 pb-2">
|
||||||
|
{#if !showNewForm}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showNewForm = true)}
|
||||||
|
class="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add someone new
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs font-semibold tracking-wider text-gray-400 uppercase">New person</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- New person form -->
|
||||||
|
{#if showNewForm}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addTraveller"
|
||||||
|
class="flex flex-1 flex-col"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
resetNew();
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 px-6 pb-6 {hasExisting ? 'pt-3' : 'pt-5'}">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="first_name" class="text-sm font-medium text-gray-700">
|
||||||
|
First name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="first_name"
|
||||||
|
name="first_name"
|
||||||
|
type="text"
|
||||||
|
bind:value={firstName}
|
||||||
|
required
|
||||||
|
autocomplete="given-name"
|
||||||
|
placeholder="Jane"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="last_name" class="text-sm font-medium text-gray-700">
|
||||||
|
Last name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="last_name"
|
||||||
|
name="last_name"
|
||||||
|
type="text"
|
||||||
|
bind:value={lastName}
|
||||||
|
required
|
||||||
|
autocomplete="family-name"
|
||||||
|
placeholder="Smith"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="email" class="text-sm font-medium text-gray-700">
|
||||||
|
Email <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
bind:value={email}
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-1">
|
||||||
|
{#if hasExisting}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showNewForm = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={handleClose}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!firstName.trim() || !lastName.trim()}
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Add traveller
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
51
src/lib/components/AppSwitcher.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
|
||||||
|
interface App {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps: App[] = [
|
||||||
|
{
|
||||||
|
name: 'Contacts',
|
||||||
|
url: 'https://cloud.campbellwireless.net/contacts',
|
||||||
|
icon: `${base}/icons/contacts.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Calendar',
|
||||||
|
url: 'https://cloud.campbellwireless.net/calendar',
|
||||||
|
icon: `${base}/icons/calendar.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Docs',
|
||||||
|
url: 'https://cloud.campbellwireless.net/docs',
|
||||||
|
icon: `${base}/icons/docs.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mail',
|
||||||
|
url: 'https://cloud.campbellwireless.net/mail',
|
||||||
|
icon: `${base}/icons/mail.png`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Photos',
|
||||||
|
url: 'https://cloud.campbellwireless.net/photos',
|
||||||
|
icon: `${base}/icons/photos.png`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style="left:59px; top:0; width:280px;"
|
||||||
|
class="absolute z-20 rounded-lg border border-gray-200 bg-white p-4 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
{#each apps as app}
|
||||||
|
<a href={app.url} class="flex flex-col items-center gap-2 rounded-lg p-3 hover:bg-gray-50">
|
||||||
|
<img src={app.icon} alt={app.name} class="h-14 w-14 rounded-xl" />
|
||||||
|
<span class="text-center text-xs text-gray-700">{app.name}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
77
src/lib/components/NavMenu.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
return $page.url.pathname === `${base}${path}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
style="width:220px; background:#FFFFFF; border-right:1px solid #E2E4E6;"
|
||||||
|
class="flex h-screen flex-shrink-0 flex-col"
|
||||||
|
>
|
||||||
|
<!-- Banner -->
|
||||||
|
<div class="flex items-center gap-3 px-5 py-5">
|
||||||
|
<img src="{base}/favicon.svg" alt="Trips" class="h-8 w-8" />
|
||||||
|
<span class="text-2xl font-bold text-gray-900">Trips</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nav links -->
|
||||||
|
<div class="flex flex-1 flex-col gap-0.5 px-3">
|
||||||
|
<!-- Dashboard -->
|
||||||
|
<a
|
||||||
|
href="{base}/dashboard"
|
||||||
|
style={isActive('/dashboard') ? 'background:#E8EAEC; color:#111827; font-weight:500;' : ''}
|
||||||
|
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<hr class="my-2 border-gray-200" />
|
||||||
|
|
||||||
|
<!-- Trips section label -->
|
||||||
|
<p class="px-3 pb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">Trips</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{base}/trips/upcoming"
|
||||||
|
style={isActive('/trips/upcoming')
|
||||||
|
? 'background:#E8EAEC; color:#111827; font-weight:500;'
|
||||||
|
: ''}
|
||||||
|
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Upcoming Trips
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{base}/trips/past"
|
||||||
|
style={isActive('/trips/past') ? 'background:#E8EAEC; color:#111827; font-weight:500;' : ''}
|
||||||
|
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
Past Trips
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Plan New Trip button -->
|
||||||
|
<div class="px-3 pb-4">
|
||||||
|
<a
|
||||||
|
href="{base}/trips/new"
|
||||||
|
style={isActive('/trips/new') ? 'background:#1d4ed8;' : ''}
|
||||||
|
class="flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Plan New Trip
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
82
src/lib/components/PlanCard.svelte
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Plan } from '$lib/server/plans.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
plan: Plan;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { plan, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
function flagEmoji(code: string | null): string {
|
||||||
|
if (!code) return '';
|
||||||
|
return code
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string | null): string {
|
||||||
|
if (!d) return '';
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
|
||||||
|
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
|
||||||
|
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
|
||||||
|
};
|
||||||
|
|
||||||
|
let dateRange = $derived(() => {
|
||||||
|
const start = formatDate(plan.start_date);
|
||||||
|
const end = formatDate(plan.end_date);
|
||||||
|
if (start && end) return `${start} — ${end}`;
|
||||||
|
if (start) return `From ${start}`;
|
||||||
|
if (end) return `Until ${end}`;
|
||||||
|
return 'Dates TBD';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 rounded-xl border border-gray-200 bg-white px-5 py-4 shadow-sm">
|
||||||
|
<!-- Flag -->
|
||||||
|
<div class="text-3xl leading-none" aria-hidden="true">
|
||||||
|
{flagEmoji(plan.country_code)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="truncate font-medium text-gray-900">{plan.city_name ?? plan.title}</p>
|
||||||
|
{#if plan.country}
|
||||||
|
<span class="text-sm text-gray-400">{plan.country}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">{dateRange()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status badge -->
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[plan.status].class}"
|
||||||
|
>
|
||||||
|
{statusConfig[plan.status].label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if onDelete}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onDelete}
|
||||||
|
class="shrink-0 rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||||
|
aria-label="Remove destination"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
151
src/lib/components/Sidebar.svelte
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import AppSwitcher from './AppSwitcher.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userName?: string | null;
|
||||||
|
}
|
||||||
|
let { userName }: Props = $props();
|
||||||
|
|
||||||
|
const initial = $derived(userName ? userName.charAt(0).toUpperCase() : '?');
|
||||||
|
let appSwitcherOpen = $state(false);
|
||||||
|
let userMenuOpen = $state(false);
|
||||||
|
|
||||||
|
function closeAll() {
|
||||||
|
appSwitcherOpen = false;
|
||||||
|
userMenuOpen = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Click-outside overlay -->
|
||||||
|
{#if appSwitcherOpen || userMenuOpen}
|
||||||
|
<div class="fixed inset-0 z-10" onclick={closeAll}></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<aside
|
||||||
|
style="width:59px; background:#F6F8FA; border-right:1px solid #E2E4E6;"
|
||||||
|
class="relative flex h-screen flex-shrink-0 flex-col items-center py-3"
|
||||||
|
>
|
||||||
|
<!-- Top: app switcher -->
|
||||||
|
<div class="relative flex flex-1 flex-col items-center gap-1 pt-1">
|
||||||
|
<button
|
||||||
|
title="App switcher"
|
||||||
|
class={`flex h-10 w-10 items-center justify-center rounded hover:bg-black/5 ${appSwitcherOpen ? 'bg-black/5' : ''}`}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
appSwitcherOpen = !appSwitcherOpen;
|
||||||
|
userMenuOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 22 22"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="5"
|
||||||
|
y="1"
|
||||||
|
width="5.5"
|
||||||
|
height="5.5"
|
||||||
|
rx="0.5"
|
||||||
|
transform="rotate(45 5 1)"
|
||||||
|
fill="#4A4A4A"
|
||||||
|
/>
|
||||||
|
<rect x="12.5" y="1.5" width="7" height="7" rx="1" fill="#4A4A4A" />
|
||||||
|
<rect x="1.5" y="12.5" width="7" height="7" rx="1" fill="#4A4A4A" />
|
||||||
|
<rect x="12.5" y="12.5" width="7" height="7" rx="1" fill="#4A4A4A" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if appSwitcherOpen}
|
||||||
|
<AppSwitcher />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom: user avatar -->
|
||||||
|
<div class="relative flex flex-col items-center pb-1">
|
||||||
|
<button
|
||||||
|
title={userName ?? ''}
|
||||||
|
style="background:#499DF2;"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium text-white select-none hover:opacity-90"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
userMenuOpen = !userMenuOpen;
|
||||||
|
appSwitcherOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if userMenuOpen}
|
||||||
|
<div
|
||||||
|
style="left:59px; bottom:0;"
|
||||||
|
class="absolute z-20 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="border-b border-gray-100 px-4 py-2">
|
||||||
|
<p class="truncate text-sm font-medium text-gray-900">{userName}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="{base}/profile"
|
||||||
|
onclick={closeAll}
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
My Profile
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="{base}/settings"
|
||||||
|
onclick={closeAll}
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<form method="POST" action="{base}/auth/signout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
61
src/lib/components/TravellerChip.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Person } from '$lib/server/travellers.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
traveller: Person;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { traveller, onDelete }: Props = $props();
|
||||||
|
|
||||||
|
// Generate a consistent colour from the name
|
||||||
|
const colours = [
|
||||||
|
'bg-blue-100 text-blue-700',
|
||||||
|
'bg-violet-100 text-violet-700',
|
||||||
|
'bg-pink-100 text-pink-700',
|
||||||
|
'bg-amber-100 text-amber-700',
|
||||||
|
'bg-teal-100 text-teal-700',
|
||||||
|
'bg-orange-100 text-orange-700',
|
||||||
|
'bg-indigo-100 text-indigo-700',
|
||||||
|
'bg-green-100 text-green-700'
|
||||||
|
];
|
||||||
|
|
||||||
|
let colour = $derived(
|
||||||
|
colours[
|
||||||
|
(traveller.first_name.charCodeAt(0) + traveller.last_name.charCodeAt(0)) % colours.length
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
let initials = $derived(
|
||||||
|
`${traveller.first_name[0] ?? ''}${traveller.last_name[0] ?? ''}`.toUpperCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
let fullName = $derived(`${traveller.first_name} ${traveller.last_name}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2.5 rounded-full border border-gray-200 bg-white py-1.5 pr-3 pl-1.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold {colour}"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium text-gray-800">{fullName}</span>
|
||||||
|
{#if traveller.email}
|
||||||
|
<span class="text-xs text-gray-400">{traveller.email}</span>
|
||||||
|
{/if}
|
||||||
|
{#if onDelete}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onDelete}
|
||||||
|
class="ml-1.5 rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
|
||||||
|
aria-label="Remove traveller"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
62
src/lib/components/TripList.svelte
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TripTile from './TripTile.svelte';
|
||||||
|
import TripTable from './TripTable.svelte';
|
||||||
|
import type { Trip } from '$lib/server/trips.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trips: Trip[];
|
||||||
|
title: string;
|
||||||
|
emptyMessage: string;
|
||||||
|
}
|
||||||
|
let { trips, title, emptyMessage }: Props = $props();
|
||||||
|
|
||||||
|
let view = $state<'tile' | 'table'>('tile');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{title}</h1>
|
||||||
|
|
||||||
|
{#if trips.length > 0}
|
||||||
|
<div class="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 p-1">
|
||||||
|
<button
|
||||||
|
onclick={() => view = 'tile'}
|
||||||
|
title="Tile view"
|
||||||
|
class="rounded p-1.5 transition-colors"
|
||||||
|
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
|
||||||
|
>
|
||||||
|
<!-- Grid icon -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'tile' ? '#111827' : '#9ca3af'} stroke-width="2">
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => view = 'table'}
|
||||||
|
title="Table view"
|
||||||
|
class="rounded p-1.5 transition-colors"
|
||||||
|
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
|
||||||
|
>
|
||||||
|
<!-- List icon -->
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'table' ? '#111827' : '#9ca3af'} stroke-width="2">
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if trips.length === 0}
|
||||||
|
<p class="text-gray-500">{emptyMessage}</p>
|
||||||
|
{:else if view === 'tile'}
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{#each trips as trip}
|
||||||
|
<TripTile {trip} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<TripTable {trips} />
|
||||||
|
{/if}
|
||||||
37
src/lib/components/TripTable.svelte
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { Trip } from '$lib/server/trips.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trips: Trip[];
|
||||||
|
}
|
||||||
|
let { trips }: Props = $props();
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return 'TBD';
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 text-left text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
<th class="pb-3 pr-6">Name</th>
|
||||||
|
<th class="pb-3 pr-6">Start</th>
|
||||||
|
<th class="pb-3 pr-6">End</th>
|
||||||
|
<th class="pb-3">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each trips as trip}
|
||||||
|
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||||
|
<td class="py-3 pr-6 font-medium text-gray-900">
|
||||||
|
<a href="{base}/trips/{trip.id}" class="hover:underline">{trip.name}</a>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 pr-6 text-gray-600">{formatDate(trip.start_date)}</td>
|
||||||
|
<td class="py-3 pr-6 text-gray-600">{formatDate(trip.end_date)}</td>
|
||||||
|
<td class="py-3 text-gray-500 max-w-xs truncate">{trip.description ?? '—'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
32
src/lib/components/TripTile.svelte
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { Trip } from '$lib/server/trips.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
trip: Trip;
|
||||||
|
}
|
||||||
|
let { trip }: Props = $props();
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return 'TBD';
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{base}/trips/{trip.id}"
|
||||||
|
class="flex flex-col rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Placeholder artwork area -->
|
||||||
|
<div class="h-32 w-full bg-gradient-to-br from-blue-100 to-blue-200"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 p-4">
|
||||||
|
<h2 class="font-semibold text-gray-900 truncate">{trip.name}</h2>
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
{formatDate(trip.start_date)} — {formatDate(trip.end_date)}
|
||||||
|
</p>
|
||||||
|
{#if trip.description}
|
||||||
|
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{trip.description}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
160
src/lib/components/TripWelcome.svelte
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Action {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
onclick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tripName: string;
|
||||||
|
onAddDestination?: () => void;
|
||||||
|
onAddTraveller?: () => void;
|
||||||
|
}
|
||||||
|
let { tripName, onAddDestination, onAddTraveller }: Props = $props();
|
||||||
|
|
||||||
|
const actions: Action[] = [
|
||||||
|
{
|
||||||
|
label: 'Destinations',
|
||||||
|
description: "Add the places you're visiting",
|
||||||
|
color: '#EFF6FF',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="1.5">
|
||||||
|
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/>
|
||||||
|
<circle cx="12" cy="9" r="2.5"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => onAddDestination?.()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Attractions & Activities',
|
||||||
|
description: 'Things to see and do',
|
||||||
|
color: '#F0FDF4',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#22C55E" stroke-width="1.5">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Transportation',
|
||||||
|
description: 'Flights, trains, car hire and more',
|
||||||
|
color: '#FFF7ED',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F97316" stroke-width="1.5">
|
||||||
|
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.99-3.99A19.79 19.79 0 0 1 4.1 6.18 2 2 0 0 1 6.08 4h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L10.09 11a16 16 0 0 0 5.91 5.91l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 24 18z"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Lodgings',
|
||||||
|
description: 'Hotels, rentals and accommodation',
|
||||||
|
color: '#FDF4FF',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#A855F7" stroke-width="1.5">
|
||||||
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
||||||
|
<polyline points="9 22 9 12 15 12 15 22"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Restaurants',
|
||||||
|
description: 'Places to eat and drink',
|
||||||
|
color: '#FFF1F2',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F43F5E" stroke-width="1.5">
|
||||||
|
<path d="M18 8h1a4 4 0 0 1 0 8h-1"/>
|
||||||
|
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/>
|
||||||
|
<line x1="6" y1="1" x2="6" y2="4"/>
|
||||||
|
<line x1="10" y1="1" x2="10" y2="4"/>
|
||||||
|
<line x1="14" y1="1" x2="14" y2="4"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Package Tours',
|
||||||
|
description: 'Pre-arranged tours and excursions',
|
||||||
|
color: '#FFFBEB',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="1.5">
|
||||||
|
<rect x="2" y="7" width="20" height="14" rx="2"/>
|
||||||
|
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
|
||||||
|
<line x1="12" y1="12" x2="12" y2="16"/>
|
||||||
|
<line x1="10" y1="14" x2="14" y2="14"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Packing List',
|
||||||
|
description: 'Everything you need to bring',
|
||||||
|
color: '#F0FDFA',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#14B8A6" stroke-width="1.5">
|
||||||
|
<path d="M9 11l3 3L22 4"/>
|
||||||
|
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'To-dos',
|
||||||
|
description: 'Visas, documents and tasks',
|
||||||
|
color: '#F8FAFC',
|
||||||
|
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="1.5">
|
||||||
|
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||||
|
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||||
|
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||||
|
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||||
|
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||||
|
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||||
|
</svg>`,
|
||||||
|
onclick: () => {}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mt-10 border-t border-gray-100 pt-10">
|
||||||
|
<div class="mb-8 text-center">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">Start building your trip</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
What would you like to add to <span class="font-medium text-gray-700">{tripName}</span>?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Who's travelling — visually distinct from plan types -->
|
||||||
|
<button
|
||||||
|
onclick={() => onAddTraveller?.()}
|
||||||
|
class="mb-4 flex w-full items-center gap-4 rounded-xl border border-indigo-100 bg-indigo-50 px-5 py-4 text-left transition-all hover:border-indigo-200 hover:shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-indigo-100">
|
||||||
|
<svg
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#6366F1"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="9" cy="7" r="4" />
|
||||||
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||||
|
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-indigo-900">Who's travelling?</p>
|
||||||
|
<p class="mt-0.5 text-sm text-indigo-500">Add the people joining this trip</p>
|
||||||
|
</div>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{#each actions as action}
|
||||||
|
<button
|
||||||
|
onclick={action.onclick}
|
||||||
|
style="background:{action.color};"
|
||||||
|
class="flex flex-col items-start gap-3 rounded-xl border border-gray-100 p-5 text-left transition-all hover:scale-[1.02] hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div>{@html action.icon}</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900">{action.label}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-gray-500">{action.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
1
src/lib/server/data/cities.json
Normal file
20
src/lib/server/db/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { createSqliteDb } from './sqlite.js';
|
||||||
|
import { runMigrations } from './migrations.js';
|
||||||
|
import type { Database } from './types.js';
|
||||||
|
|
||||||
|
function createDb(): Database {
|
||||||
|
const url = env.DATABASE_URL ?? 'file:trips.db';
|
||||||
|
|
||||||
|
if (url.startsWith('file:') || url.endsWith('.db')) {
|
||||||
|
return createSqliteDb(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postgres support: add a postgres.ts adapter and import it here
|
||||||
|
// e.g. if (url.startsWith('postgres')) return createPostgresDb(url);
|
||||||
|
throw new Error(`Unsupported DATABASE_URL scheme: ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db: Database = createDb();
|
||||||
|
|
||||||
|
runMigrations(db);
|
||||||
112
src/lib/server/db/migrations.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import type { Database } from './types.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export function runMigrations(db: Database): void {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS trips (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cities (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
country_code TEXT NOT NULL,
|
||||||
|
population INTEGER
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS plans (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idea',
|
||||||
|
parent_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
notes TEXT,
|
||||||
|
city_id INTEGER REFERENCES cities(id),
|
||||||
|
city_name TEXT,
|
||||||
|
country TEXT,
|
||||||
|
country_code TEXT,
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS travellers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS people (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
is_self INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS trip_travellers (
|
||||||
|
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||||
|
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (trip_id, person_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Seed cities table on first run
|
||||||
|
const row = db.get<{ count: number }>('SELECT COUNT(*) as count FROM cities');
|
||||||
|
if ((row?.count ?? 0) === 0) {
|
||||||
|
seedCities(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedCities(db: Database): void {
|
||||||
|
try {
|
||||||
|
const data = readFileSync(join(__dirname, '../data/cities.json'), 'utf-8');
|
||||||
|
const cities: { name: string; country: string; country_code: string; population: number }[] =
|
||||||
|
JSON.parse(data);
|
||||||
|
let id = 1;
|
||||||
|
for (const city of cities) {
|
||||||
|
db.run(
|
||||||
|
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[id++, city.name, city.country, city.country_code, city.population]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[db] Seeded ${cities.length} cities`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[db] Failed to seed cities:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/lib/server/db/sqlite.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import BetterSqlite3 from 'better-sqlite3';
|
||||||
|
import type { Database } from './types.js';
|
||||||
|
|
||||||
|
export function createSqliteDb(url: string): Database {
|
||||||
|
// Strip the "file:" prefix if present
|
||||||
|
const path = url.startsWith('file:') ? url.slice(5) : url;
|
||||||
|
const db = new BetterSqlite3(path);
|
||||||
|
|
||||||
|
// Enable WAL mode for better concurrent read performance
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
return {
|
||||||
|
run(sql, params = []) {
|
||||||
|
db.prepare(sql).run(params);
|
||||||
|
},
|
||||||
|
get<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
|
||||||
|
return db.prepare(sql).get(params) as T | undefined;
|
||||||
|
},
|
||||||
|
all<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
|
||||||
|
return db.prepare(sql).all(params) as T[];
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
10
src/lib/server/db/types.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface Database {
|
||||||
|
/** Execute a statement that returns no rows (INSERT, UPDATE, DELETE, CREATE, etc.) */
|
||||||
|
run(sql: string, params?: unknown[]): void;
|
||||||
|
/** Execute a SELECT and return the first matching row, or undefined */
|
||||||
|
get<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | undefined;
|
||||||
|
/** Execute a SELECT and return all matching rows */
|
||||||
|
all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
|
||||||
|
/** Close the database connection */
|
||||||
|
close(): void;
|
||||||
|
}
|
||||||
124
src/lib/server/plans.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { db } from './db/index.js';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export type PlanType =
|
||||||
|
| 'destination'
|
||||||
|
| 'activity'
|
||||||
|
| 'transport'
|
||||||
|
| 'lodging'
|
||||||
|
| 'restaurant'
|
||||||
|
| 'tour'
|
||||||
|
| 'packing'
|
||||||
|
| 'todo';
|
||||||
|
|
||||||
|
export type PlanStatus = 'idea' | 'tentative' | 'confirmed';
|
||||||
|
|
||||||
|
export interface Plan {
|
||||||
|
id: string;
|
||||||
|
trip_id: string;
|
||||||
|
user_id: string;
|
||||||
|
type: PlanType;
|
||||||
|
status: PlanStatus;
|
||||||
|
parent_id: string | null;
|
||||||
|
title: string;
|
||||||
|
notes: string | null;
|
||||||
|
city_id: number | null;
|
||||||
|
city_name: string | null;
|
||||||
|
country: string | null;
|
||||||
|
country_code: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
position: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface City {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
country_code: string;
|
||||||
|
population: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDestinationInput {
|
||||||
|
tripId: string;
|
||||||
|
userId: string;
|
||||||
|
cityId?: number;
|
||||||
|
cityName: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
status?: PlanStatus;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDestination(input: CreateDestinationInput): Plan {
|
||||||
|
const id = randomUUID();
|
||||||
|
const maxPos = db.get<{ pos: number }>(
|
||||||
|
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
|
||||||
|
[input.tripId, input.userId]
|
||||||
|
);
|
||||||
|
const position = maxPos?.pos ?? 0;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO plans (id, trip_id, user_id, type, status, title, notes, city_id, city_name, country, country_code, start_date, end_date, position)
|
||||||
|
VALUES (?, ?, ?, 'destination', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
input.tripId,
|
||||||
|
input.userId,
|
||||||
|
input.status ?? 'idea',
|
||||||
|
input.cityName,
|
||||||
|
input.notes ?? null,
|
||||||
|
input.cityId ?? null,
|
||||||
|
input.cityName,
|
||||||
|
input.country,
|
||||||
|
input.countryCode,
|
||||||
|
input.startDate ?? null,
|
||||||
|
input.endDate ?? null,
|
||||||
|
position
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlansForTrip(tripId: string, userId: string): Plan[] {
|
||||||
|
return db.all<Plan>(
|
||||||
|
`SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`,
|
||||||
|
[tripId, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlanCountForTrip(tripId: string, userId: string): number {
|
||||||
|
const row = db.get<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM plans WHERE trip_id = ? AND user_id = ?`,
|
||||||
|
[tripId, userId]
|
||||||
|
);
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchCities(query: string): City[] {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
const pattern = `%${query.trim()}%`;
|
||||||
|
return db.all<City>(
|
||||||
|
`SELECT id, name, country, country_code, population
|
||||||
|
FROM cities
|
||||||
|
WHERE name LIKE ? COLLATE NOCASE
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
|
||||||
|
population DESC NULLS LAST
|
||||||
|
LIMIT 10`,
|
||||||
|
[pattern, `${query.trim()}%`]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlan(planId: string, userId: string): void {
|
||||||
|
// Verify the plan belongs to the user
|
||||||
|
const plan = db.get<Plan>('SELECT * FROM plans WHERE id = ? AND user_id = ?', [planId, userId]);
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found or not authorized');
|
||||||
|
}
|
||||||
|
db.run('DELETE FROM plans WHERE id = ?', [planId]);
|
||||||
|
}
|
||||||
142
src/lib/server/travellers.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { db } from './db/index.js';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export interface Person {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string | null;
|
||||||
|
is_self: number; // 1 = owner's own profile
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Global people management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function getSelfProfile(userId: string): Person | undefined {
|
||||||
|
return db.get<Person>(`SELECT * FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`, [userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertSelfProfile(
|
||||||
|
userId: string,
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
email?: string
|
||||||
|
): Person {
|
||||||
|
const existing = getSelfProfile(userId);
|
||||||
|
if (existing) {
|
||||||
|
db.run(
|
||||||
|
`UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
[firstName, lastName, email ?? null, existing.id]
|
||||||
|
);
|
||||||
|
return db.get<Person>('SELECT * FROM people WHERE id = ?', [existing.id])!;
|
||||||
|
}
|
||||||
|
const id = randomUUID();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)`,
|
||||||
|
[id, userId, firstName, lastName, email ?? null]
|
||||||
|
);
|
||||||
|
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPerson(
|
||||||
|
userId: string,
|
||||||
|
firstName: string,
|
||||||
|
lastName: string,
|
||||||
|
email?: string
|
||||||
|
): Person {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||||
|
[id, userId, firstName, lastName, email ?? null]
|
||||||
|
);
|
||||||
|
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPeopleForUser(userId: string): Person[] {
|
||||||
|
// Own profile first, then others alphabetically
|
||||||
|
return db.all<Person>(
|
||||||
|
`SELECT * FROM people WHERE user_id = ?
|
||||||
|
ORDER BY is_self DESC, first_name ASC, last_name ASC`,
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trip assignment
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function addPersonToTrip(tripId: string, personId: string): void {
|
||||||
|
const maxPos = db.get<{ pos: number }>(
|
||||||
|
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM trip_travellers WHERE trip_id = ?`,
|
||||||
|
[tripId]
|
||||||
|
);
|
||||||
|
db.run(`INSERT OR IGNORE INTO trip_travellers (trip_id, person_id, position) VALUES (?, ?, ?)`, [
|
||||||
|
tripId,
|
||||||
|
personId,
|
||||||
|
maxPos?.pos ?? 0
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTraveller(input: {
|
||||||
|
tripId: string;
|
||||||
|
userId: string;
|
||||||
|
personId?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
}): void {
|
||||||
|
let personId = input.personId;
|
||||||
|
|
||||||
|
// If no personId provided, create a new person
|
||||||
|
if (!personId) {
|
||||||
|
if (!input.firstName || !input.lastName) {
|
||||||
|
throw new Error('First name and last name are required when creating a new person');
|
||||||
|
}
|
||||||
|
const person = createPerson(input.userId, input.firstName, input.lastName, input.email);
|
||||||
|
personId = person.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the person to the trip
|
||||||
|
addPersonToTrip(input.tripId, personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTravellersForTrip(tripId: string, userId: string): Person[] {
|
||||||
|
return db.all<Person>(
|
||||||
|
`SELECT p.* FROM people p
|
||||||
|
JOIN trip_travellers tt ON tt.person_id = p.id
|
||||||
|
WHERE tt.trip_id = ? AND p.user_id = ?
|
||||||
|
ORDER BY tt.position ASC, p.first_name ASC`,
|
||||||
|
[tripId, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTravellerCountForTrip(tripId: string, userId: string): number {
|
||||||
|
const row = db.get<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM trip_travellers tt
|
||||||
|
JOIN people p ON p.id = tt.person_id
|
||||||
|
WHERE tt.trip_id = ? AND p.user_id = ?`,
|
||||||
|
[tripId, userId]
|
||||||
|
);
|
||||||
|
return row?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTravellerFromTrip(tripId: string, personId: string, userId: string): void {
|
||||||
|
// Verify the person belongs to the user and is on the trip
|
||||||
|
const traveller = db.get<Person>(
|
||||||
|
`SELECT p.* FROM people p
|
||||||
|
JOIN trip_travellers tt ON tt.person_id = p.id
|
||||||
|
WHERE tt.trip_id = ? AND p.id = ? AND p.user_id = ?`,
|
||||||
|
[tripId, personId, userId]
|
||||||
|
);
|
||||||
|
if (!traveller) {
|
||||||
|
throw new Error('Traveller not found or not authorized');
|
||||||
|
}
|
||||||
|
db.run(`DELETE FROM trip_travellers WHERE trip_id = ? AND person_id = ?`, [tripId, personId]);
|
||||||
|
}
|
||||||
89
src/lib/server/trips.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { db } from './db/index.js';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export interface Trip {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTripInput {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTripInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTrip(input: CreateTripInput): Trip {
|
||||||
|
const id = randomUUID();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO trips (id, user_id, name, description, start_date, end_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
input.userId,
|
||||||
|
input.name,
|
||||||
|
input.description ?? null,
|
||||||
|
input.startDate ?? null,
|
||||||
|
input.endDate ?? null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return db.get<Trip>('SELECT * FROM trips WHERE id = ?', [id])!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateTrip(id: string, userId: string, input: UpdateTripInput): Trip | undefined {
|
||||||
|
db.run(
|
||||||
|
`UPDATE trips
|
||||||
|
SET name = ?, description = ?, start_date = ?, end_date = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ? AND user_id = ?`,
|
||||||
|
[
|
||||||
|
input.name,
|
||||||
|
input.description ?? null,
|
||||||
|
input.startDate ?? null,
|
||||||
|
input.endDate ?? null,
|
||||||
|
id,
|
||||||
|
userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return db.get<Trip>('SELECT * FROM trips WHERE id = ? AND user_id = ?', [id, userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTripById(id: string, userId: string): Trip | undefined {
|
||||||
|
return db.get<Trip>('SELECT * FROM trips WHERE id = ? AND user_id = ?', [id, userId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUpcomingTrips(userId: string): Trip[] {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return db.all<Trip>(
|
||||||
|
`SELECT * FROM trips
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (end_date IS NULL OR end_date >= ?)
|
||||||
|
ORDER BY start_date ASC NULLS FIRST`,
|
||||||
|
[userId, today]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPastTrips(userId: string): Trip[] {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
return db.all<Trip>(
|
||||||
|
`SELECT * FROM trips
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND end_date IS NOT NULL
|
||||||
|
AND end_date < ?
|
||||||
|
ORDER BY end_date DESC`,
|
||||||
|
[userId, today]
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/routes/(protected)/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
if (!session?.user) {
|
||||||
|
redirect(303, `${base}/login`);
|
||||||
|
}
|
||||||
|
return { session };
|
||||||
|
};
|
||||||
14
src/routes/(protected)/+layout.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import NavMenu from '$lib/components/NavMenu.svelte';
|
||||||
|
|
||||||
|
let { children, data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<Sidebar userName={data.session.user?.name ?? data.session.user?.email} />
|
||||||
|
<NavMenu />
|
||||||
|
<main class="flex-1 overflow-auto bg-white p-8">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
9
src/routes/(protected)/api/cities/+server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { searchCities } from '$lib/server/plans.js';
|
||||||
|
|
||||||
|
export const GET: RequestHandler = ({ url }) => {
|
||||||
|
const q = url.searchParams.get('q') ?? '';
|
||||||
|
const cities = searchCities(q);
|
||||||
|
return json(cities);
|
||||||
|
};
|
||||||
10
src/routes/(protected)/dashboard/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Dashboard — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>
|
||||||
32
src/routes/(protected)/profile/+page.server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import { getSelfProfile, upsertSelfProfile } from '$lib/server/travellers.js';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
const profile = getSelfProfile(userId);
|
||||||
|
return { profile };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
save: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const firstName = (data.get('first_name') as string)?.trim();
|
||||||
|
const lastName = (data.get('last_name') as string)?.trim();
|
||||||
|
const email = (data.get('email') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
return fail(400, { error: 'First and last name are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = upsertSelfProfile(userId, firstName, lastName, email);
|
||||||
|
return { success: true, profile };
|
||||||
|
}
|
||||||
|
};
|
||||||
104
src/routes/(protected)/profile/+page.svelte
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
let profile = $derived(form?.profile ?? data.profile);
|
||||||
|
|
||||||
|
let saved = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>My Profile — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-lg">
|
||||||
|
<h1 class="mb-1 text-2xl font-bold text-gray-900">My Profile</h1>
|
||||||
|
<p class="mb-8 text-sm text-gray-500">
|
||||||
|
Your traveller profile is shared across all your trips so you don't need to enter your details
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if saved}
|
||||||
|
<div class="mb-4 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
|
||||||
|
Profile saved.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/save"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') {
|
||||||
|
saved = true;
|
||||||
|
setTimeout(() => (saved = false), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="first_name" class="text-sm font-medium text-gray-700">
|
||||||
|
First name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="first_name"
|
||||||
|
name="first_name"
|
||||||
|
type="text"
|
||||||
|
value={profile?.first_name ?? ''}
|
||||||
|
required
|
||||||
|
autocomplete="given-name"
|
||||||
|
placeholder="Jane"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="last_name" class="text-sm font-medium text-gray-700">
|
||||||
|
Last name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="last_name"
|
||||||
|
name="last_name"
|
||||||
|
type="text"
|
||||||
|
value={profile?.last_name ?? ''}
|
||||||
|
required
|
||||||
|
autocomplete="family-name"
|
||||||
|
placeholder="Smith"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="email" class="text-sm font-medium text-gray-700">
|
||||||
|
Email <span class="text-xs font-normal text-gray-400">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
value={profile?.email ?? ''}
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="jane@example.com"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
157
src/routes/(protected)/trips/[id]/+page.server.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { error, fail } from '@sveltejs/kit';
|
||||||
|
import { getTripById, updateTrip } from '$lib/server/trips.js';
|
||||||
|
import { createDestination, getPlansForTrip, deletePlan } from '$lib/server/plans.js';
|
||||||
|
import { addTraveller, getTravellersForTrip, getPeopleForUser, removeTravellerFromTrip } from '$lib/server/travellers.js';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
const trip = getTripById(event.params.id, userId);
|
||||||
|
if (!trip) error(404, 'Trip not found');
|
||||||
|
|
||||||
|
const plans = getPlansForTrip(trip.id, userId);
|
||||||
|
const travellers = getTravellersForTrip(trip.id, userId);
|
||||||
|
const people = getPeopleForUser(userId);
|
||||||
|
|
||||||
|
return { trip, plans, planCount: plans.length, travellers, travellerCount: travellers.length, people };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const name = (data.get('name') as string)?.trim();
|
||||||
|
const description = (data.get('description') as string)?.trim();
|
||||||
|
const startDate = (data.get('start_date') as string)?.trim() || undefined;
|
||||||
|
const endDate = (data.get('end_date') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
if (!name) return fail(400, { error: 'Trip name is required' });
|
||||||
|
|
||||||
|
const trip = updateTrip(event.params.id, userId, {
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!trip) error(404, 'Trip not found');
|
||||||
|
|
||||||
|
return { success: true, trip };
|
||||||
|
},
|
||||||
|
|
||||||
|
addDestination: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const trip = getTripById(event.params.id, userId);
|
||||||
|
if (!trip) return fail(404, { error: 'Trip not found' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const cityName = (data.get('city_name') as string)?.trim();
|
||||||
|
const country = (data.get('country') as string)?.trim();
|
||||||
|
const countryCode = (data.get('country_code') as string)?.trim();
|
||||||
|
const cityIdRaw = data.get('city_id') as string;
|
||||||
|
const startDate = (data.get('start_date') as string)?.trim() || undefined;
|
||||||
|
const endDate = (data.get('end_date') as string)?.trim() || undefined;
|
||||||
|
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
|
||||||
|
|
||||||
|
if (!cityName || !country || !countryCode) {
|
||||||
|
return fail(400, { error: 'Please select a city' });
|
||||||
|
}
|
||||||
|
|
||||||
|
createDestination({
|
||||||
|
tripId: trip.id,
|
||||||
|
userId,
|
||||||
|
cityId: cityIdRaw ? parseInt(cityIdRaw) : undefined,
|
||||||
|
cityName,
|
||||||
|
country,
|
||||||
|
countryCode,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
addTraveller: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const trip = getTripById(event.params.id, userId);
|
||||||
|
if (!trip) return fail(404, { error: 'Trip not found' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const personId = (data.get('person_id') as string)?.trim();
|
||||||
|
const firstName = (data.get('first_name') as string)?.trim();
|
||||||
|
const lastName = (data.get('last_name') as string)?.trim();
|
||||||
|
const email = (data.get('email') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
// If person_id is provided, add existing person to trip
|
||||||
|
if (personId) {
|
||||||
|
addTraveller({ tripId: trip.id, userId, personId });
|
||||||
|
} else {
|
||||||
|
// Otherwise, create new person and add to trip
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
return fail(400, { error: 'First and last name are required' });
|
||||||
|
}
|
||||||
|
addTraveller({ tripId: trip.id, userId, firstName, lastName, email });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTraveller: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const trip = getTripById(event.params.id, userId);
|
||||||
|
if (!trip) return fail(404, { error: 'Trip not found' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const personId = (data.get('person_id') as string)?.trim();
|
||||||
|
|
||||||
|
if (!personId) {
|
||||||
|
return fail(400, { error: 'Person ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
removeTravellerFromTrip(trip.id, personId, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to remove traveller' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removePlan: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const trip = getTripById(event.params.id, userId);
|
||||||
|
if (!trip) return fail(404, { error: 'Trip not found' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const planId = (data.get('plan_id') as string)?.trim();
|
||||||
|
|
||||||
|
if (!planId) {
|
||||||
|
return fail(400, { error: 'Plan ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
deletePlan(planId, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return fail(400, { error: err instanceof Error ? err.message : 'Failed to remove plan' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
425
src/routes/(protected)/trips/[id]/+page.svelte
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import TripWelcome from '$lib/components/TripWelcome.svelte';
|
||||||
|
import AddDestinationModal from '$lib/components/AddDestinationModal.svelte';
|
||||||
|
import AddTravellerModal from '$lib/components/AddTravellerModal.svelte';
|
||||||
|
import PlanCard from '$lib/components/PlanCard.svelte';
|
||||||
|
import TravellerChip from '$lib/components/TravellerChip.svelte';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
let trip = $derived(form?.trip ?? data.trip);
|
||||||
|
let plans = $derived(data.plans ?? []);
|
||||||
|
let planCount = $derived(data.planCount ?? 0);
|
||||||
|
let travellers = $derived(data.travellers ?? []);
|
||||||
|
let travellerCount = $derived(data.travellerCount ?? 0);
|
||||||
|
let people = $derived(data.people ?? []);
|
||||||
|
let tripTravellerIds = $derived(travellers.map((t) => t.id));
|
||||||
|
let editing = $state(false);
|
||||||
|
let showAddDestination = $state(false);
|
||||||
|
let showAddTraveller = $state(false);
|
||||||
|
let showAddMenu = $state(false);
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: 'Destinations',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="1.5"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddDestination = true;
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Attractions & Activities',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22C55E" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Transportation',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F97316" stroke-width="1.5"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.99-3.99A19.79 19.79 0 0 1 4.1 6.18 2 2 0 0 1 6.08 4h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L10.09 11a16 16 0 0 0 5.91 5.91l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 24 18z"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Lodgings',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#A855F7" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Restaurants',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F43F5E" stroke-width="1.5"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Package Tours',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Packing List',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#14B8A6" stroke-width="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'To-dos',
|
||||||
|
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="1.5"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
|
||||||
|
onclick: () => {
|
||||||
|
showAddMenu = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatDate(d: string | null) {
|
||||||
|
if (!d) return 'TBD';
|
||||||
|
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{trip.name} — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<AddDestinationModal open={showAddDestination} onclose={() => (showAddDestination = false)} />
|
||||||
|
<AddTravellerModal
|
||||||
|
open={showAddTraveller}
|
||||||
|
onclose={() => (showAddTraveller = false)}
|
||||||
|
{people}
|
||||||
|
{tripTravellerIds}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6 flex items-start justify-between gap-4">
|
||||||
|
{#if editing}
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Edit Trip</h1>
|
||||||
|
{:else}
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{trip.name}</h1>
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
{#if planCount > 0 || travellerCount > 0}
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddMenu = !showAddMenu)}
|
||||||
|
class="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add to trip
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showAddMenu}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-10"
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
onclick={() => (showAddMenu = false)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 z-20 mt-1 w-56 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<!-- Travellers — separate from plan types -->
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showAddTraveller = true;
|
||||||
|
showAddMenu = false;
|
||||||
|
}}
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="#6366F1"
|
||||||
|
stroke-width="1.5"
|
||||||
|
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path
|
||||||
|
d="M16 3.13a4 4 0 0 1 0 7.75"
|
||||||
|
/></svg
|
||||||
|
>
|
||||||
|
Travellers
|
||||||
|
</button>
|
||||||
|
<hr class="my-1 border-gray-100" />
|
||||||
|
{#each menuItems as item}
|
||||||
|
<button
|
||||||
|
onclick={item.onclick}
|
||||||
|
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
{@html item.icon}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
class="flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editing}
|
||||||
|
<!-- Edit form -->
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/update"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ result, update }) => {
|
||||||
|
update();
|
||||||
|
if (result.type === 'success') editing = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex flex-col gap-5"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-gray-700"
|
||||||
|
>Trip name <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={trip.name}
|
||||||
|
required
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="start_date" class="text-sm font-medium text-gray-700">Start date</label>
|
||||||
|
<input
|
||||||
|
id="start_date"
|
||||||
|
name="start_date"
|
||||||
|
type="date"
|
||||||
|
value={trip.start_date ?? ''}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!trip.start_date}
|
||||||
|
onchange={(e) => {
|
||||||
|
const input = document.getElementById('start_date') as HTMLInputElement;
|
||||||
|
input.disabled = (e.target as HTMLInputElement).checked;
|
||||||
|
if (input.disabled) input.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
I don't know yet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="end_date" class="text-sm font-medium text-gray-700">End date</label>
|
||||||
|
<input
|
||||||
|
id="end_date"
|
||||||
|
name="end_date"
|
||||||
|
type="date"
|
||||||
|
value={trip.end_date ?? ''}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!trip.end_date}
|
||||||
|
onchange={(e) => {
|
||||||
|
const input = document.getElementById('end_date') as HTMLInputElement;
|
||||||
|
input.disabled = (e.target as HTMLInputElement).checked;
|
||||||
|
if (input.disabled) input.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
I don't know yet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="description" class="text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="4"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
>{trip.description ?? ''}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (editing = false)}
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<!-- Read view -->
|
||||||
|
<dl class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold tracking-wider text-gray-400 uppercase">Dates</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-700">
|
||||||
|
{formatDate(trip.start_date)} — {formatDate(trip.end_date)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if trip.description}
|
||||||
|
<div>
|
||||||
|
<dt class="text-xs font-semibold tracking-wider text-gray-400 uppercase">Description</dt>
|
||||||
|
<dd class="mt-1 text-sm whitespace-pre-wrap text-gray-700">{trip.description}</dd>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Travellers section (always visible when there are travellers) -->
|
||||||
|
{#if travellerCount > 0}
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
|
||||||
|
Travelling
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each travellers as traveller (traveller.id)}
|
||||||
|
{@const formId = `remove-traveller-${traveller.id}`}
|
||||||
|
{@const submitForm = () => {
|
||||||
|
const form = document.getElementById(formId) as HTMLFormElement;
|
||||||
|
form?.requestSubmit();
|
||||||
|
}}
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
method="POST"
|
||||||
|
action="?/removeTraveller"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ update }) => {
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="contents"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="person_id" value={traveller.id} />
|
||||||
|
<TravellerChip {traveller} onDelete={submitForm} />
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddTraveller = true)}
|
||||||
|
class="flex items-center gap-1.5 rounded-full border border-dashed border-gray-300 px-3 py-1.5 text-sm text-gray-400 hover:border-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if planCount === 0}
|
||||||
|
<TripWelcome
|
||||||
|
tripName={trip.name}
|
||||||
|
onAddDestination={() => (showAddDestination = true)}
|
||||||
|
onAddTraveller={() => (showAddTraveller = true)}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- Plans list -->
|
||||||
|
<div class="mt-8">
|
||||||
|
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
|
||||||
|
Destinations
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
{#each plans.filter((p) => p.type === 'destination') as plan (plan.id)}
|
||||||
|
{@const formId = `remove-plan-${plan.id}`}
|
||||||
|
{@const submitForm = () => {
|
||||||
|
const form = document.getElementById(formId) as HTMLFormElement;
|
||||||
|
form?.requestSubmit();
|
||||||
|
}}
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
method="POST"
|
||||||
|
action="?/removePlan"
|
||||||
|
use:enhance={() => {
|
||||||
|
return ({ update }) => {
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="contents"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="plan_id" value={plan.id} />
|
||||||
|
<PlanCard {plan} onDelete={submitForm} />
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
32
src/routes/(protected)/trips/new/+page.server.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { createTrip } from '$lib/server/trips.js';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) return fail(401, { error: 'Not authenticated' });
|
||||||
|
|
||||||
|
const data = await event.request.formData();
|
||||||
|
const name = (data.get('name') as string)?.trim();
|
||||||
|
const description = (data.get('description') as string)?.trim();
|
||||||
|
const startDate = (data.get('start_date') as string)?.trim() || undefined;
|
||||||
|
const endDate = (data.get('end_date') as string)?.trim() || undefined;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return fail(400, { error: 'Trip name is required', name, description, startDate, endDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const trip = createTrip({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
redirect(303, `${base}/trips/${trip.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
112
src/routes/(protected)/trips/new/+page.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
let { form } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Plan New Trip — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-xl">
|
||||||
|
<h1 class="mb-6 text-2xl font-bold text-gray-900">Plan New Trip</h1>
|
||||||
|
|
||||||
|
{#if form?.error}
|
||||||
|
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{form.error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="POST" use:enhance class="flex flex-col gap-5">
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-gray-700"
|
||||||
|
>Trip name <span class="text-red-500">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
value={form?.name ?? ''}
|
||||||
|
placeholder="e.g. Summer in Japan"
|
||||||
|
required
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dates -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="start_date" class="text-sm font-medium text-gray-700">Start date</label>
|
||||||
|
<input
|
||||||
|
id="start_date"
|
||||||
|
name="start_date"
|
||||||
|
type="date"
|
||||||
|
value={form?.startDate ?? ''}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onchange={(e) => {
|
||||||
|
const input = document.getElementById('start_date') as HTMLInputElement;
|
||||||
|
input.disabled = (e.target as HTMLInputElement).checked;
|
||||||
|
if (input.disabled) input.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
I don't know yet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="end_date" class="text-sm font-medium text-gray-700">End date</label>
|
||||||
|
<input
|
||||||
|
id="end_date"
|
||||||
|
name="end_date"
|
||||||
|
type="date"
|
||||||
|
value={form?.endDate ?? ''}
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onchange={(e) => {
|
||||||
|
const input = document.getElementById('end_date') as HTMLInputElement;
|
||||||
|
input.disabled = (e.target as HTMLInputElement).checked;
|
||||||
|
if (input.disabled) input.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
I don't know yet
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label for="description" class="text-sm font-medium text-gray-700">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
rows="4"
|
||||||
|
placeholder="What's this trip about?"
|
||||||
|
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
|
||||||
|
>{form?.description ?? ''}</textarea
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<a
|
||||||
|
href="../dashboard"
|
||||||
|
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
11
src/routes/(protected)/trips/past/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getPastTrips } from '$lib/server/trips.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
return { trips: getPastTrips(userId) };
|
||||||
|
};
|
||||||
10
src/routes/(protected)/trips/past/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TripList from '$lib/components/TripList.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Past Trips — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<TripList trips={data.trips} title="Past Trips" emptyMessage="No past trips yet." />
|
||||||
11
src/routes/(protected)/trips/upcoming/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { getUpcomingTrips } from '$lib/server/trips.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (!userId) error(401, 'Not authenticated');
|
||||||
|
|
||||||
|
return { trips: getUpcomingTrips(userId) };
|
||||||
|
};
|
||||||
14
src/routes/(protected)/trips/upcoming/+page.svelte
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import TripList from '$lib/components/TripList.svelte';
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Upcoming Trips — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<TripList
|
||||||
|
trips={data.trips}
|
||||||
|
title="Upcoming Trips"
|
||||||
|
emptyMessage="No upcoming trips. Click 'Plan New Trip' to get started."
|
||||||
|
/>
|
||||||
7
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async (event) => {
|
||||||
|
return {
|
||||||
|
session: await event.locals.auth()
|
||||||
|
};
|
||||||
|
};
|
||||||
7
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
12
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
const session = await event.locals.auth();
|
||||||
|
if (session?.user) {
|
||||||
|
redirect(303, `${base}/dashboard`);
|
||||||
|
} else {
|
||||||
|
redirect(303, `${base}/login`);
|
||||||
|
}
|
||||||
|
};
|
||||||
8
src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
return {
|
||||||
|
signinUrl: `${env.AUTH_URL}/signin/synology`
|
||||||
|
};
|
||||||
|
};
|
||||||
22
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let form: HTMLFormElement;
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Sign in — Trips</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
|
||||||
|
<input type="hidden" name="csrfToken" />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex min-h-[60vh] items-center justify-center">
|
||||||
|
<p class="text-gray-500">Redirecting to sign in…</p>
|
||||||
|
</div>
|
||||||
1
static/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
BIN
static/icons/calendar.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
static/icons/contacts.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
static/icons/docs.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
static/icons/mail.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
static/icons/photos.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
15
svelte.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import adapter from "@sveltejs/adapter-node";
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
import type { Config } from "@sveltejs/kit";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
paths: {
|
||||||
|
base: "/trips",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
allowedHosts: ['cloud.campbellwireless.net']
|
||||||
|
}
|
||||||
|
});
|
||||||