trips) adding initial commit

This commit is contained in:
2026-02-18 20:23:34 -05:00
commit 1068145261
67 changed files with 3602 additions and 0 deletions

10
.dockerignore Normal file
View 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
View 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
View 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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

15
.prettierrc Normal file
View 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"
}
}
]
}

0
CLAUDE.md Normal file
View File

45
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
@import "tailwindcss";

17
src/app.d.ts vendored Normal file
View 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
View 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
View 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
View 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);

View 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

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

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

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

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

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

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

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

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

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

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

View 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
View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

File diff suppressed because one or more lines are too long

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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." />

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

View 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."
/>

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
return {
session: await event.locals.auth()
};
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
static/icons/contacts.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
static/icons/docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
static/icons/mail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/icons/photos.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

3
static/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

15
svelte.config.ts Normal file
View 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
View 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
View 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']
}
});