commit 5e113454ee1a4ce520f06738215af822f0aa4e03 Author: JB Date: Fri Jul 24 21:31:35 2026 -0400 Checkpoint SHD dashboard design system diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..603f1de --- /dev/null +++ b/.env.development @@ -0,0 +1,11 @@ +#/-------------------[DOTENV_PUBLIC_KEY]--------------------/ +#/ public-key encryption for .env files / +#/ [how it works](https://dotenvx.com/encryption) / +#/----------------------------------------------------------/ +DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167" + +# .env.development +TDR_DATABASE_PATH=encrypted:BFvKNEzpR2G6g6J33jBZHEVLChTcT292ahKJfJW3a/GKTD1I7QQzp+S+KMFYGVwWnRoPjBezfddAPQwSPWb4ifWGAw2DHSoasqNam2ZbrE0fwcibsqkX/HYJ7EQ/4vM/vt6uy9HmJ5N9OwjhU6e0wIT7TyU= +DISCORD_CLIENT_ID=encrypted:BLuEvsF1X2SdGXhfgySXJPvqdGATQGam7pdH2IDImFjKARB32gV3Yf+Uulus2j7FOsN6jVotrTbYTgguEPHKbYbTIcLb0/+Itc6pc+oNvsqcK9NccfakonAEr5VHriIIIqm17w9+xax3whUE9bIj4rpc5h0= +DISCORD_CLIENT_SECRET=encrypted:BI1tDKKWLaXVFWZqJLo2Vk/bl8APJVHzo3ePfreEVbfAx3P0R/oG2SZhHK6gljsTsVRj8z3x4e9GgIfdGDpOVrSe1YLN3AD0+fR5GFHZgzopRrT2695zlGSVN26p/JgNABLAJ9jWITfBdzP6rk9eGet8B46BbOyWE+dyE8HHyQVo +DISCORD_REDIRECT_URI=encrypted:BMDJ6dVfzllmKqzHQXL6s1YtYuN66NbkW2mXKFlcZa4rTsekYPJwc7MeNMRkfKoGBv+YwJGzDBqOM/acSml9DSCa9ZgY2ELkqmU/xhcZAxVCThBpANXRocPQCgwum4fIuz4fjlEI/ucyarqTR56YQrQG1nR0G6F43R8mSVAEA/9W4LqCi/PBewzNqZc= diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7572cad --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Local path used by the embedded @tursodatabase/database engine. +# Relative paths are resolved from the project directory. +TDR_DATABASE_PATH=./data/shd-build.db + +# Discord OAuth2 application credentials. +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +# Local: http://localhost:5173/auth/discord/callback +# Production: https://shd.build/auth/discord/callback +DISCORD_REDIRECT_URI=http://localhost:5173/auth/discord/callback diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..3eb400f --- /dev/null +++ b/.env.production @@ -0,0 +1,11 @@ +#/-------------------[DOTENV_PUBLIC_KEY]--------------------/ +#/ public-key encryption for .env files / +#/ [how it works](https://dotenvx.com/encryption) / +#/----------------------------------------------------------/ +DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d" + +# .env.production +TDR_DATABASE_PATH=encrypted:BF6m/a7eY9vr0nQgR/szp3OlQMAww1E3360Ri83R0kmvEhPhEKO3L4YKNySUqUC70wXphwSb8Odtp4Bu4K7vjS7duI1TB15qsoEr1xCMQ/+s9bByg5Fv1wk4NPJpbMM4iBghhORscVd81V6gbOUBiCi7aYQ= +DISCORD_CLIENT_ID=encrypted:BEdmWvxXdwetGw8f2r3k0HS9RbWMDMqqBcuujWhg2x1NISqO+o5b7aoW+AC2BitCSBkuFfIjkzLym1za9qNT3kC4yB0kE1O2MzY7HWIzh64IcJK4qvzmqMMR/T6ykUDiopnAUKG6oqzE2KjPA//FMhKdo7c= +DISCORD_CLIENT_SECRET=encrypted:BJ40+cXThndtnpTFuk4BhS8t4juecVKNSCrZRU+i1sQaut2RAE64P+bPrYPD5U9gbu1HnkIXDrxWZ17UbY/MOrWaURa8Yo6uavbEuMcbnrRO+tfpPyTMNhu50lhRWVcyeZ92Jev4PUuMpUA5mYCLEkvubexMN5AaJ94E1Y1n5NNV +DISCORD_REDIRECT_URI=encrypted:BIxQ5vrLcJnAYQ8Lfazqv1gnFNtyzEgI6JFqDWXevPJBGItO8JN0DJv4qYGUQkiKOAmreGHO8WykzWp4nicwDO2FSXsEw+oe2SaqhR3ko6NMp6fcp5ORhdq/5c2NpbKsvd1rME6UQD8ILb+TDFQe06JnI5n9cSTmqiFOQA21oBY1d8i6eo/zOg== diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..c2b49ed --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +set -e + +echo "Protecting environment files with dotenvx..." +npm run env:stage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c6e2e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test +!.env.development +!.env.production +.env.keys +.env.keys.* + +# Embedded Turso database files +/data/*.db +/data/*.db-* +/data/*.turso +/data/*.turso-* + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..28d1e67 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode"] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e012d3 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# SHD.build + +A SvelteKit application for creating and sharing loadouts for *The Division Resurgence*. + +The first increment includes a responsive discovery page, an anonymous builder draft saved to local storage, authentication UI boundaries, an admin data-management preview, and an embedded Turso database foundation. See `docs/architecture.md` for the staged implementation plan. + +## Database + +The server uses the in-process `@tursodatabase/database` package with a local +SQLite-compatible file—there is no Turso Cloud dependency. + +Create or update the local database: + +```sh +npm run db:migrate +``` + +The default file is `data/shd-build.db`. Copy `.env.example` to `.env` to +override its location. + +Seed the initial local accounts once: + +```sh +npm run db:seed-users +``` + +This creates administrator `Aewyn` and standard user `Quartermaster`. Strong +temporary passwords are generated and displayed once; only salted Argon2id +hashes are stored. + +## Develop + +```sh +npm run dev +``` + +## Encrypted environment files + +Development and production settings live in the committed, encrypted +`.env.development` and `.env.production` files. Private decryption keys live +only in the ignored `.env.keys` file; back it up in a password manager. + +```sh +npm run env:decrypt # decrypt both files for editing +npm run env:encrypt # encrypt both files again +npm run env:stage # encrypt and git-add both files +npm run env:decrypt:development # operate on one environment +npm run env:decrypt:production +``` + +The repository pre-commit hook runs `env:stage`, so a commit always replaces +staged plaintext environment files with encrypted versions. Git does not expose +a pre-add hook, so use `npm run env:stage` instead of directly adding these two +files when possible. + +Never commit or lose `.env.keys`. Production should receive +`DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying +the keys file onto the server. + +## Validate + +```sh +npm run check +npm run build +``` + +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 +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.16.5 create --template minimal --types ts --install npm . +``` + +## 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. +The shared visual language and dashboard conventions are documented in +[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md). diff --git a/database/schema.sql b/database/schema.sql new file mode 100644 index 0000000..7aea0ba --- /dev/null +++ b/database/schema.sql @@ -0,0 +1,93 @@ +-- Initial embedded Turso schema. Every statement is safe to run repeatedly. +CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT, discord_id TEXT UNIQUE, role TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'draft', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS build_items (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, slot_id TEXT NOT NULL, item_definition_id TEXT NOT NULL, configuration_json TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS ratings (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, value INTEGER NOT NULL CHECK(value BETWEEN 1 AND 5), PRIMARY KEY(user_id, build_id)); +CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id), body TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS favorites (user_id TEXT NOT NULL REFERENCES users(id), build_id TEXT NOT NULL REFERENCES builds(id) ON DELETE CASCADE, created_at TEXT NOT NULL, PRIMARY KEY(user_id, build_id)); +CREATE TABLE IF NOT EXISTS equipment_types (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, category TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS stat_definitions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, value_kind TEXT NOT NULL, min_value REAL, max_value REAL, unit TEXT, active INTEGER NOT NULL DEFAULT 1); +CREATE TABLE IF NOT EXISTS stat_equipment_types (stat_id TEXT NOT NULL REFERENCES stat_definitions(id), equipment_type_id TEXT NOT NULL REFERENCES equipment_types(id), PRIMARY KEY(stat_id, equipment_type_id)); +CREATE TABLE IF NOT EXISTS audit_log (id TEXT PRIMARY KEY, actor_id TEXT NOT NULL REFERENCES users(id), entity_type TEXT NOT NULL, entity_id TEXT NOT NULL, action TEXT NOT NULL, before_json TEXT, after_json TEXT, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS roles (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); +CREATE TABLE IF NOT EXISTS permissions (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT); +CREATE TABLE IF NOT EXISTS user_roles (user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY(user_id, role_id)); +CREATE TABLE IF NOT EXISTS role_permissions (role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, scope_json TEXT, PRIMARY KEY(role_id, permission_id)); +CREATE TABLE IF NOT EXISTS oauth_states (state_hash TEXT PRIMARY KEY, provider TEXT NOT NULL, mode TEXT NOT NULL, user_id TEXT REFERENCES users(id) ON DELETE CASCADE, expires_at TEXT NOT NULL, created_at TEXT NOT NULL); +CREATE UNIQUE INDEX IF NOT EXISTS users_username_nocase_idx ON users(username COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS stat_attributes ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('Offensive', 'Defensive')), + category TEXT NOT NULL CHECK(category IN ('Weapon', 'Skill', 'Character')), + calculation_type TEXT NOT NULL CHECK(calculation_type IN ('Additive', 'Multiplicative')), + unit TEXT NOT NULL DEFAULT 'flat', + gear_min REAL, + gear_max REAL, + weapon_min REAL, + weapon_max REAL, + skill_chip_min REAL, + skill_chip_max REAL, + cap_value REAL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK((gear_min IS NULL AND gear_max IS NULL) OR (gear_min IS NOT NULL AND gear_max IS NOT NULL AND gear_min <= gear_max)), + CHECK((weapon_min IS NULL AND weapon_max IS NULL) OR (weapon_min IS NOT NULL AND weapon_max IS NOT NULL AND weapon_min <= weapon_max)), + CHECK((skill_chip_min IS NULL AND skill_chip_max IS NULL) OR (skill_chip_min IS NOT NULL AND skill_chip_max IS NOT NULL AND skill_chip_min <= skill_chip_max)), + CHECK(cap_value IS NULL OR cap_value >= 0) +); +CREATE UNIQUE INDEX IF NOT EXISTS stat_attributes_name_nocase_idx ON stat_attributes(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS talents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + category TEXT NOT NULL CHECK(category IN ('Weapon', 'Backpack', 'Vest')), + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS talents_name_category_nocase_idx ON talents(name COLLATE NOCASE, category); +CREATE TABLE IF NOT EXISTS talent_tiers ( + id TEXT PRIMARY KEY, + talent_id TEXT NOT NULL REFERENCES talents(id) ON DELETE CASCADE, + tier TEXT NOT NULL CHECK(tier IN ('I', 'II')), + description TEXT NOT NULL, + trigger_text TEXT NOT NULL, + effect_text TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(talent_id, tier) +); +CREATE TABLE IF NOT EXISTS talent_triggers ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + supports_uptime INTEGER NOT NULL DEFAULT 0 CHECK(supports_uptime IN (0, 1)), + min_uptime REAL, + max_uptime REAL, + calculation_script TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK( + (supports_uptime = 0 AND min_uptime IS NULL AND max_uptime IS NULL) + OR + (supports_uptime = 1 AND min_uptime IS NOT NULL AND max_uptime IS NOT NULL + AND min_uptime >= 0 AND max_uptime <= 100 AND min_uptime <= max_uptime) + ) +); +CREATE UNIQUE INDEX IF NOT EXISTS talent_triggers_name_nocase_idx ON talent_triggers(name COLLATE NOCASE); +CREATE TABLE IF NOT EXISTS talent_tier_triggers ( + talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE, + trigger_id TEXT NOT NULL REFERENCES talent_triggers(id), + PRIMARY KEY(talent_tier_id, trigger_id) +); +CREATE TABLE IF NOT EXISTS talent_tier_effects ( + id TEXT PRIMARY KEY, + talent_tier_id TEXT NOT NULL REFERENCES talent_tiers(id) ON DELETE CASCADE, + stat_attribute_id TEXT NOT NULL REFERENCES stat_attributes(id), + value REAL NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(talent_tier_id, stat_attribute_id) +); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md new file mode 100644 index 0000000..2a3fbfb --- /dev/null +++ b/docs/DESIGN_SYSTEM.md @@ -0,0 +1,100 @@ +# SHD.build Design System + +This document defines the visual language for SHD.build. New pages should feel like part of the same tactical data network as the admin dashboard, while public pages may use more space and larger editorial typography. + +## Design principles + +1. **Tactical, not military cosplay.** Use operational language, compact information density, restrained status indicators, and precise alignment. Avoid camouflage, distressed textures, or decorative weapon imagery in utility interfaces. +2. **Data first.** Hierarchy comes from grouping, typography, borders, and spacing. Admin tools should resemble a dependable control surface rather than a collection of marketing cards. +3. **Orange means action.** SHD orange identifies primary actions, active navigation, focus, and important values. It should not cover large areas. +4. **State is always visible.** Loading, disabled, active, archived, configured, error, and success states need both visual and textual indicators. +5. **Square and engineered.** Panels and controls use square corners, fine borders, tight spacing, and deliberate grid alignment. + +## Tokens + +The canonical tokens live in `src/app.css` under `:root`. + +| Purpose | Token | Value | +| --- | --- | --- | +| Page background | `--shd-bg` | `#0a0c0d` | +| Base surface | `--shd-surface` | `#111415` | +| Raised surface | `--shd-surface-raised` | `#141718` | +| Primary border | `--shd-border` | `#303536` | +| Main text | `--shd-text` | `#ecebe5` | +| Muted text | `--shd-text-muted` | `#858b8d` | +| SHD action orange | `--shd-orange` | `#f6a821` | +| Success | `--shd-success` | `#75c08c` | +| Destructive/error | `--shd-danger` | `#df8069` | +| Informational/defensive | `--shd-info` | `#70b9d6` | + +Use the spacing tokens for new components. Prefer `8px`, `12px`, and `20px` gaps. Dashboard controls use the shared `40px` height when practical. + +## Typography + +- The current UI stack is `Arial, Helvetica, sans-serif`. +- Page and module headings are uppercase, heavy, and tightly spaced. +- Eyebrows and table labels use 7–10px text with 1–2px letter spacing. +- Body and form text should normally remain between 9–12px in dashboards. +- Public/editorial copy may use Georgia for a restrained field-manual character. +- Never use low-contrast tiny text for information required to complete a task. + +## Layout patterns + +### Public pages + +- Maximum content width: approximately 1400–1640px. +- Larger headings and more generous vertical rhythm are appropriate. +- Preserve the grid background and orange accents. + +### Admin dashboard + +- Use `admin-page`, `admin-command`, `admin-dashboard`, `admin-sidebar`, and `admin-content`. +- Each module starts with `module-toolbar`, followed by `module-kpis`. +- Editors use `dashboard-editor` and appear before the affected table. +- Data collections use `dashboard-table`, `dashboard-row`, and `table-heading`. +- Keep tables dense and horizontally scrollable rather than wrapping columns unpredictably. + +### Forms + +- Labels are uppercase and letter-spaced. +- Inputs use dark surfaces, one-pixel borders, and clear disabled styling. +- Related min/max fields remain visually paired. +- Primary save actions sit at the lower right. Cancel is secondary. +- Destructive/archive actions are visually separated from save actions. + +## Interaction rules + +- Every interactive element needs a pointer cursor unless disabled. +- Buttons have hover, pressed, focus-visible, pending, and disabled states. +- Forms that send requests should use `PendingForm.svelte` or equivalent enhancement to prevent duplicate submissions. +- Pending buttons use `aria-busy="true"` and remain disabled until the request completes. +- Respect `prefers-reduced-motion`. + +## Color semantics + +- Orange: primary action, current location, selected operational value. +- Green: online, active, configured, or successful. +- Red: destructive action, error, or offensive classification when context requires it. +- Blue: defensive or informational classification. +- Gray: inactive, archived, unavailable, or supporting metadata. + +Do not communicate state by color alone; include a label, icon, or pattern. + +## Responsive behavior + +- Collapse the dashboard sidebar into a two-column module grid below 900px. +- KPI tiles become two columns on small screens. +- Tier editors stack vertically on tablets. +- Tables retain a useful minimum width and scroll horizontally. +- Primary and secondary toolbar actions may expand equally on narrow screens. + +## Adding a new admin module + +1. Add the route beneath `src/routes/admin`. +2. Register it in the admin layout navigation. +3. Start with the module toolbar and KPI row. +4. Reuse dashboard editor and table classes. +5. Use the shared tokens instead of introducing near-duplicate colors. +6. Verify keyboard focus, pending behavior, empty states, mobile layout, and archived data behavior. + +If a new interface needs a genuinely new pattern, document it here when introducing it. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..fd61109 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,47 @@ +# SHD.build architecture + +This project is intentionally being built in vertical slices. + +## Planned stack + +- SvelteKit 2 / Svelte 5 with TypeScript +- Embedded Turso Database via `@tursodatabase/database` for durable application data +- Auth.js-compatible server authentication with Discord OAuth and credentials providers +- Argon2id password hashing, server-side sessions, CSRF protection, and role checks +- Browser `localStorage` only for anonymous build drafts + +## Product boundaries + +- Public: browse, sort, and view published builds and guides. +- Anonymous: create and edit local drafts. +- Authenticated: publish, rate, comment, favorite, copy, and manage owned builds. +- Editor: manage builder source data. +- Admin: manage users, roles, source data, and audit history. + +Authorization must be checked on the server for every mutation. Hiding an admin link is not authorization. + +Local credentials are stored as Argon2id PHC strings with a unique random salt +per password. Current work factors follow OWASP's 19 MiB / 2 iteration / +single-lane minimum profile and can be increased over time; successful login +will eventually rehash credentials whose parameters are outdated. + +## Database runtime + +The application uses Turso Database in-process through its native Node package. +It opens a local SQLite-compatible database file and does not use Turso Cloud, +libSQL HTTP, remote replicas, auth tokens, or synchronization. + +The database defaults to `./data/shd-build.db`. Production deployments must +provide a persistent writable filesystem and can override the location with +`TDR_DATABASE_PATH`. This rules out ephemeral serverless filesystems unless a +persistent volume is attached. + +## Increment plan + +1. Product shell and anonymous local-draft builder. +2. Embedded Turso migrations, repositories, and seed data. +3. Credentials and Discord authentication. +4. Full equipment/stat builder driven by database definitions. +5. Publishing, discovery, ratings, comments, favorites, and copy flow. +6. Rich build guides and moderation. +7. Admin editors, validation, audit log, and data import/export. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3fbb748 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1716 @@ +{ + "name": "shd-build", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "shd-build", + "version": "0.0.1", + "dependencies": { + "@tursodatabase/database": "^0.7.1", + "argon2": "^0.45.1" + }, + "devDependencies": { + "@dotenvx/dotenvx": "^2.17.4", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.1.1", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + } + }, + "node_modules/@dotenvx/dotenvx": { + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-2.17.4.tgz", + "integrity": "sha512-hzW9xsf9bOUoY1V7xXnl+vZfjqdsdUs0zSOWzm6Zqfcm55nq3YaV+lXmeSzT3ju7xQ6fdSlLjwDQFqrIsMV3wA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@dotenvx/primitives": "^2.1.0", + "@dotenvx/tooling": "^1.0.3", + "yocto-spinner": "^1.2.1" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/primitives": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-2.1.0.tgz", + "integrity": "sha512-GIpMSSgjZk4URRCtAw12vBmobnw3VeSKL+p7/y8ypWvTHFW+StAMZvq4wCjdX+QKuw4alXu0IMGW6QPhKo6ufg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@dotenvx/tooling": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@dotenvx/tooling/-/tooling-1.0.3.tgz", + "integrity": "sha512-Gd6qQol/ICb4MPRWpiIH56y+pR9AVB7kPBkbhrLSGLtAiF4Qgy+k651/f2pze0Xbm3H5XewbPfIm4y9K+ic30Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", + "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.70.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz", + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "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 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tursodatabase/database": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database/-/database-0.7.1.tgz", + "integrity": "sha512-RPEkpAoxKk0aY8spsUpT0MID7AUq7CHnqHOkA0zTG546XPkP/mkkQr1ZwpUer69z4BItSaDq+OQ3XrxVOFfsHg==", + "license": "MIT", + "dependencies": { + "@tursodatabase/database-common": "^0.7.1" + }, + "optionalDependencies": { + "@tursodatabase/database-darwin-arm64": "0.7.1", + "@tursodatabase/database-linux-arm64-gnu": "0.7.1", + "@tursodatabase/database-linux-x64-gnu": "0.7.1", + "@tursodatabase/database-win32-x64-msvc": "0.7.1" + } + }, + "node_modules/@tursodatabase/database-common": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-common/-/database-common-0.7.1.tgz", + "integrity": "sha512-tyEKiRJDZSqLTbJQYhvY9Q2pGbBMwhKb8YS5T9TNOaDy1HvZMDbiypYqvtBw04f02QladUIsmpVBlsmFDb+RFA==", + "license": "MIT" + }, + "node_modules/@tursodatabase/database-darwin-arm64": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-darwin-arm64/-/database-darwin-arm64-0.7.1.tgz", + "integrity": "sha512-lTki3P+yH5GawkBL2zmc0hU7nJQ92WCiKlb3XDfRpkX6UukwmnuAZAYM0oWK5rbOzBd0plZUGdItZGjwM+lPPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@tursodatabase/database-linux-arm64-gnu": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-linux-arm64-gnu/-/database-linux-arm64-gnu-0.7.1.tgz", + "integrity": "sha512-28dQmMUm1Nzvc2B6lRz6sbIRt5bJBVrnRdy2LpEUnYbU1aTvgLsUgfl4NQONbaaKTGzeKdxe8wONjfwkPbgsaA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tursodatabase/database-linux-x64-gnu": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-linux-x64-gnu/-/database-linux-x64-gnu-0.7.1.tgz", + "integrity": "sha512-8gENsVswYpwNeuRv/MQJ+aqqT0cwRaDxoaPCeST4IDfzLihl8C5qOrzqJ315pEOCquMKQNVsH8EGPmdjJd7l4g==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tursodatabase/database-win32-x64-msvc": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@tursodatabase/database-win32-x64-msvc/-/database-win32-x64-msvc-0.7.1.tgz", + "integrity": "sha512-+O3k4VPTlgOvzcRfogep8Tsf6MjwZ9BAzC7QD2QAIYZUvraqcXvv+dZTgZnkRJ6XI5Fu/3ePr8a6SkqbXjhoMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/argon2": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.45.1.tgz", + "integrity": "sha512-skm+/WCjkGqCQxF7FG1LuZXM5yvbFjgbfiCGsud2oLgaDhh6b6dbH0b1EkghbM+xx4Bj8Ape+KKgixoIlWZicQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.1.0", + "node-addon-api": "^8.9.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", + "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@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.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.3.tgz", + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.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" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yocto-spinner": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", + "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "yoctocolors": "^2.1.1" + }, + "engines": { + "node": ">=18.19" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..80449ce --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "shd-build", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "dotenvx run -f .env.development -- vite dev", + "build": "dotenvx run -f .env.production -- vite build", + "preview": "vite preview", + "db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs", + "db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs", + "env:encrypt": "node scripts/env-files.mjs encrypt", + "env:decrypt": "node scripts/env-files.mjs decrypt", + "env:encrypt:development": "dotenvx encrypt -f .env.development", + "env:decrypt:development": "dotenvx decrypt -f .env.development", + "env:encrypt:production": "dotenvx encrypt -f .env.production", + "env:decrypt:production": "dotenvx decrypt -f .env.production", + "env:stage": "node scripts/env-files.mjs stage", + "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": { + "@dotenvx/dotenvx": "^2.17.4", + "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/kit": "^2.63.0", + "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/node": "^26.1.1", + "svelte": "^5.56.1", + "svelte-check": "^4.6.0", + "typescript": "^6.0.3", + "vite": "^8.0.16" + }, + "dependencies": { + "@tursodatabase/database": "^0.7.1", + "argon2": "^0.45.1" + } +} diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs new file mode 100644 index 0000000..5314bdb --- /dev/null +++ b/scripts/db-migrate.mjs @@ -0,0 +1,24 @@ +import { connect } from '@tursodatabase/database'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; + +const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'); +const schemaPath = resolve('./database/schema.sql'); + +await mkdir(dirname(databasePath), { recursive: true }); + +const database = await connect(databasePath); +const schema = await readFile(schemaPath, 'utf8'); + +// The project schema deliberately avoids semicolons inside string literals. +const statements = schema + .split(';') + .map((statement) => statement.trim()) + .filter(Boolean); + +for (const statement of statements) { + await database.exec(statement); +} + +console.log(`Applied ${statements.length} schema statements to ${databasePath}`); diff --git a/scripts/env-files.mjs b/scripts/env-files.mjs new file mode 100644 index 0000000..04f5168 --- /dev/null +++ b/scripts/env-files.mjs @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +const files = ['.env.development', '.env.production']; +const action = process.argv[2]; +const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git'; +const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js'); + +function run(command, args) { + const result = spawnSync(command, args, { cwd: process.cwd(), stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +function dotenvx(command, file) { + run(process.execPath, [dotenvxCli, command, '-f', file]); +} + +if (!['encrypt', 'decrypt', 'stage'].includes(action)) { + console.error('Usage: node scripts/env-files.mjs '); + process.exit(2); +} + +for (const file of files) { + dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file); +} + +if (action === 'stage') { + run(gitCommand, ['add', '--', ...files]); + console.log('Encrypted and staged environment files.'); +} diff --git a/scripts/seed-initial-users.mjs b/scripts/seed-initial-users.mjs new file mode 100644 index 0000000..e481516 --- /dev/null +++ b/scripts/seed-initial-users.mjs @@ -0,0 +1,48 @@ +import { connect } from '@tursodatabase/database'; +import { argon2id, hash } from 'argon2'; +import { randomBytes, randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db'); +const database = await connect(databasePath); + +const users = [ + { username: 'Aewyn', role: 'admin' }, + { username: 'Quartermaster', role: 'user' } +]; + +const findUser = await database.prepare( + 'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE' +); +const insertUser = await database.prepare( + 'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)' +); + +for (const user of users) { + const existing = await findUser.get(user.username); + + if (existing) { + console.log(`${user.username}: already exists (${existing.role}); left unchanged`); + continue; + } + + // 120 bits of entropy encoded without punctuation that is awkward to paste. + const temporaryPassword = randomBytes(15).toString('base64url'); + const passwordHash = await hash(temporaryPassword, { + type: argon2id, + memoryCost: 19_456, + timeCost: 2, + parallelism: 1 + }); + + await insertUser.run( + randomUUID(), + user.username, + passwordHash, + user.role, + new Date().toISOString() + ); + + console.log(`${user.username} (${user.role}) temporary password: ${temporaryPassword}`); +} diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..4a06dfa --- /dev/null +++ b/src/app.css @@ -0,0 +1,54 @@ + :root{ + --shd-bg:#0a0c0d; + --shd-surface:#111415; + --shd-surface-raised:#141718; + --shd-surface-active:#15191a; + --shd-border:#303536; + --shd-border-soft:#272c2d; + --shd-text:#ecebe5; + --shd-text-muted:#858b8d; + --shd-text-dim:#5d6567; + --shd-orange:#f6a821; + --shd-orange-hover:#ffc45d; + --shd-success:#75c08c; + --shd-danger:#df8069; + --shd-info:#70b9d6; + --shd-radius:0px; + --shd-gap-xs:5px; + --shd-gap-sm:8px; + --shd-gap-md:12px; + --shd-gap-lg:20px; + --shd-control-height:40px; + --shd-header-height:76px; + --shd-transition:transform .12s ease,filter .18s ease,opacity .18s ease,box-shadow .18s ease; + } + *{box-sizing:border-box} html{scroll-behavior:smooth} body{margin:0;background:var(--shd-bg);color:var(--shd-text);font-family:Arial,Helvetica,sans-serif} button,input{font:inherit} + body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.08;background-image:linear-gradient(rgba(255,255,255,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.08) 1px,transparent 1px);background-size:48px 48px} + header{height:76px;border-bottom:1px solid #292d2f;display:flex;align-items:center;padding:0 max(24px,5vw);position:relative;z-index:5;background:rgba(10,12,13,.93)} + .brand{display:flex;align-items:center;gap:12px;color:#fff;text-decoration:none;font-weight:900;letter-spacing:1.4px}.brand-mark{display:grid;place-items:center;width:36px;height:36px;border:2px solid #f6a821;border-radius:50%;color:#f6a821}.accent{color:#f6a821} + nav{margin:auto;display:flex;height:100%}nav button{border:0;background:none;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;cursor:pointer;border-bottom:2px solid transparent}nav button.active,nav button:hover{color:#fff;border-color:#f6a821} + .sign-in{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:11px;font-weight:800;letter-spacing:1.4px;cursor:pointer}.sign-in span{color:#f6a821;margin-left:12px} + main{position:relative;z-index:1}.hero{min-height:660px;display:grid;grid-template-columns:1fr 1fr;max-width:1400px;margin:auto;padding:76px 5vw 40px;gap:40px;overflow:hidden}.hero-copy{padding-top:28px}.eyebrow{color:#9da1a2;font-size:10px;letter-spacing:2.4px;font-weight:800}.eyebrow span{display:inline-block;width:28px;height:2px;background:#f6a821;vertical-align:middle;margin-right:10px} + h1,h2,h3,p{margin-top:0}.hero h1{font-size:clamp(58px,7vw,105px);line-height:.85;letter-spacing:-5px;margin:28px 0}.hero h1 em{font-style:normal;color:#f6a821}.lede{color:#9da1a2;font-family:Georgia,serif;font-size:18px;line-height:1.65;max-width:620px} + .hero-actions{display:flex;gap:14px;margin:36px 0}.primary,.secondary{border:0;padding:16px 22px;font-size:11px;font-weight:900;letter-spacing:1.6px;cursor:pointer}.primary{background:#f6a821;color:#111}.primary:hover{background:#ffc45d}.primary span{margin-left:16px}.secondary{background:transparent;color:#ddd;border:1px solid #3a3e40} + .metrics{border-top:1px solid #292d2f;padding-top:28px;display:flex;gap:60px}.metrics div{display:flex;flex-direction:column}.metrics strong{font-size:25px}.metrics span{color:#666d70;font-size:9px;letter-spacing:1.7px;margin-top:6px} + .loadout-preview{min-height:520px;position:relative;border:1px solid #343738;background:radial-gradient(circle at 50% 46%,#30312c 0,#151819 33%,#0d0f10 68%);clip-path:polygon(5% 0,100% 0,100% 92%,95% 100%,0 100%,0 8%)}.scan-lines{position:absolute;inset:0;opacity:.15;background:repeating-linear-gradient(0deg,transparent,transparent 5px,#fff 6px)}.preview-top{padding:22px;display:flex;justify-content:space-between;font-size:9px;letter-spacing:2px;color:#8d9294}.preview-top b{color:#f6a821}.agent{height:385px;position:relative;display:grid;place-items:center}.agent-shape{width:145px;height:285px;background:linear-gradient(90deg,#1d2121,#737269 46%,#292c2b 53%,#101212);clip-path:polygon(33% 0,67% 0,78% 14%,72% 32%,89% 58%,78% 100%,22% 100%,12% 58%,28% 32%,22% 14%);filter:drop-shadow(0 10px 18px #000);display:grid;place-items:center}.agent-shape span{color:#f6a821;font-weight:900;opacity:.5;transform:rotate(-90deg);letter-spacing:6px}.orbit{position:absolute;border:1px solid #555;border-radius:50%}.orbit-one{width:330px;height:330px}.orbit-two{width:250px;height:250px;border-color:#f6a82144} + .gear-node{position:absolute;width:74px;background:#141718;border:1px solid #555;border-left:2px solid #f6a821;padding:8px;display:flex;flex-direction:column}.gear-node small{color:#777;font-size:7px;letter-spacing:1px}.gear-node b{font-size:12px;margin-top:3px}.n-0{left:8%;top:12%}.n-1{right:5%;top:24%}.n-2{left:4%;bottom:22%}.n-3{right:8%;bottom:12%}.preview-name{position:absolute;bottom:22px;left:25px;display:flex;flex-direction:column}.preview-name small{color:#f6a821;font-size:8px}.preview-name strong{font-size:18px;margin:4px 0}.preview-name span{font-size:8px;color:#777} + .featured{background:#101314;padding:70px max(24px,5vw) 90px}.section-heading{max-width:1240px;margin:0 auto 28px;display:flex;align-items:end;justify-content:space-between}.section-heading h2{font-size:34px;margin:8px 0}.section-heading button{color:#aaa;background:none;border:0;font-size:10px;letter-spacing:1px}.build-grid{max-width:1240px;margin:auto;display:grid;grid-template-columns:repeat(3,1fr);gap:20px}.build-grid article{background:#16191a;border:1px solid #2d3132;border-top:2px solid var(--build-color)}.card-top{display:flex;justify-content:space-between;padding:13px 16px;font-size:9px;letter-spacing:1px}.card-top span:first-child{color:var(--build-color)}.weapon-art{height:135px;background:linear-gradient(135deg,#1e2324,#111);display:grid;place-items:center;position:relative;overflow:hidden}.weapon-art div{width:65%;height:14px;background:var(--build-color);clip-path:polygon(0 20%,65% 20%,75% 0,100% 25%,94% 65%,66% 60%,58% 100%,47% 100%,49% 62%,0 72%);opacity:.65}.weapon-art span{position:absolute;font-size:9px;color:#ffffff33;letter-spacing:4px}.card-body{padding:20px}.card-body>p{font-size:8px;color:var(--build-color);letter-spacing:1.5px}.card-body h3{text-transform:uppercase;font-size:19px}.tags{display:flex;gap:7px}.tags span{font-size:8px;border:1px solid #3b4041;padding:5px 7px;color:#aeb1b2}.author{display:flex;align-items:center;gap:8px;border-top:1px solid #2d3132;margin-top:18px;padding-top:15px;font-size:8px;color:#777}.author>span:last-child{margin-left:auto}.avatar{display:grid;place-items:center;width:25px;height:25px;border-radius:50%;background:#2f3435;color:#f6a821} + .app-page{max-width:1320px;margin:auto;padding:60px 5vw;min-height:calc(100vh - 76px)}.page-title{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-title h1{font-size:50px;margin:6px 0}.page-title input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.actions{display:flex;align-items:center;gap:14px}.actions>span{font-size:9px;color:#6a6;opacity:0}.actions>span.visible{opacity:1}.builder-layout{display:grid;grid-template-columns:1fr 340px;gap:24px}.slot-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot-card{text-align:left;min-height:160px;padding:20px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer;display:grid;grid-template-columns:1fr auto}.slot-card:hover{border-color:#f6a821}.slot-card .slot-label{font-size:9px;text-transform:uppercase;letter-spacing:1.5px;color:#777}.slot-icon{font-size:30px;color:#f6a821}.slot-card strong,.slot-card small,.slot-card b{grid-column:1/3}.slot-card strong{font-size:20px}.slot-card small{color:#888}.slot-card b{color:#f6a821}.summary,.data-panel{background:#141718;border:1px solid #303536;padding:25px}.summary h2{font-size:25px}.stat{display:grid;grid-template-columns:1fr auto;gap:8px;margin:20px 0}.stat span{color:#999;font-size:11px}.stat i{height:3px;background:#f6a821;grid-column:1/3}.summary hr{border:0;border-top:1px solid #333;margin:28px 0}.mini{display:flex;justify-content:space-between;font-size:11px;margin:14px 0}.mini span{color:#888}.notice{font-size:11px;line-height:1.5;color:#747a7c;border-left:2px solid #f6a821;padding-left:12px;margin-top:30px} + .admin-grid{display:grid;grid-template-columns:260px 1fr;gap:20px}.admin-nav{display:flex;flex-direction:column}.admin-nav button{display:flex;justify-content:space-between;background:#111415;color:#9ca0a1;border:0;border-bottom:1px solid #292d2f;padding:18px;text-align:left}.admin-nav button:first-child{color:#f6a821;border-left:2px solid #f6a821}.table-head{display:flex;justify-content:space-between}.table-head p{font-size:11px;color:#777}.table-head input{height:38px;background:#0e1011;color:white;border:1px solid #333;padding:0 12px}.data-row{display:grid;grid-template-columns:1.4fr 1.2fr .8fr .6fr .3fr;gap:15px;align-items:center;border-top:1px solid #2b3031;padding:18px 4px;font-size:11px}.data-row.labels{font-size:8px;color:#6f7577}.data-row button{background:none;border:0;color:#f6a821;font-size:9px}.status{color:#73bd8a;font-size:8px} + .auth-page,.empty-page{min-height:calc(100vh - 76px);display:grid;place-items:center;padding:40px}.auth-page section{width:min(430px,100%);background:#141718;border:1px solid #343839;border-top:2px solid #f6a821;padding:38px}.auth-page h1,.empty-page h1{font-size:46px;margin-bottom:12px}.auth-page>section>p:nth-of-type(2){color:#929798;line-height:1.5}.discord{width:100%;padding:15px;background:#5865f2;color:#fff;border:0;font-size:11px;font-weight:bold;letter-spacing:1px}.divider{text-align:center;border-top:1px solid #333;margin:30px 0 20px}.divider span{position:relative;top:-7px;background:#141718;padding:0 12px;font-size:8px;color:#666}.auth-page label{display:block;font-size:9px;letter-spacing:1px;color:#999;margin:15px 0}.auth-page input{width:100%;display:block;margin-top:7px;background:#0e1011;border:1px solid #35393a;color:#fff;padding:13px}.full{width:100%;margin:8px 0 20px}.auth-page small{display:block;color:#676d6e;line-height:1.5}.empty-page{text-align:center}.empty-page p:not(.eyebrow){color:#888}.toast{position:fixed;z-index:20;right:25px;bottom:25px;background:#f6a821;color:#111;padding:14px 20px;font-size:11px;font-weight:bold} + @media(max-width:900px){header nav{display:none}.sign-in{margin-left:auto}.hero{grid-template-columns:1fr;padding-top:35px}.hero h1{font-size:62px}.loadout-preview{min-height:500px}.build-grid{grid-template-columns:1fr}.builder-layout,.admin-grid{grid-template-columns:1fr}.slot-grid{grid-template-columns:1fr}.summary{order:-1}.page-title{align-items:start;gap:25px}.page-title input{font-size:30px;width:100%}}@media(max-width:560px){.hero{padding-left:20px;padding-right:20px}.hero h1{font-size:48px;letter-spacing:-3px}.metrics{gap:25px}.hero-actions{flex-direction:column}.loadout-preview{display:none}.hero{min-height:auto;padding-bottom:60px}.page-title{flex-direction:column}.admin-grid{display:block}.data-panel{overflow:auto}.data-row{min-width:650px}.brand{font-size:13px}} + +/* Routed application compatibility: preserve the original SHD visual system. */ +.site-header{height:76px}.site-header .brand-mark{border:2px solid #f6a821;border-radius:50%;background:transparent;color:#f6a821;font-size:9px}.site-header .brand b{color:#f6a821}.site-header nav a{display:grid;place-items:center;color:#868b8d;padding:0 22px;font-size:12px;font-weight:800;letter-spacing:1.5px;border-bottom:2px solid transparent}.site-header nav a.active,.site-header nav a:hover{color:#fff;border-color:#f6a821}.site-header .account{margin-left:auto;display:flex;align-items:center;gap:15px}.site-header .account>a{display:flex;flex-direction:column;font-size:11px;font-weight:800}.site-header .account small{font-size:7px;color:#6f7577}.site-header .account button{background:none;border:1px solid #45494a;color:#fff;padding:12px 18px;font-size:10px;font-weight:800;letter-spacing:1.2px} +.page{width:100%;max-width:1640px;margin:auto;padding:52px clamp(18px,3vw,48px);min-height:calc(100vh - 76px)}.page.hero{max-width:1400px;padding:76px 5vw 40px}.page-head{display:flex;justify-content:space-between;align-items:end;margin-bottom:35px}.page-head h1{font-size:50px;margin:6px 0;text-transform:uppercase}.page-head h2{font-size:25px;margin:5px 0}.page-head>div>input{background:transparent;border:0;border-bottom:1px solid #333;color:#fff;font-size:42px;font-weight:800;max-width:600px}.muted{color:#858b8d;line-height:1.55} +.panel,.card,.table{background:#141718;border:1px solid #303536}.panel,.card{padding:24px}.grid{display:grid;gap:20px}.cards{grid-template-columns:repeat(3,minmax(0,1fr))}.card{border-top:2px solid #f6a821}.card small,.label{font-size:8px;color:#747a7c;letter-spacing:1.4px}.card h3{font-size:19px;text-transform:uppercase;margin:14px 0}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.field{display:block;font-size:8px;letter-spacing:1.2px;color:#999;margin:9px 0}.field input,.field select,.field textarea,input,select,textarea{display:block;width:100%;margin-top:6px;background:#0d0f10;border:1px solid #363b3c;color:#fff;padding:11px}.field textarea{resize:vertical}.form-actions{display:flex;justify-content:flex-end;gap:10px;border-top:1px solid #303536;margin-top:14px;padding-top:14px}.error,.success{font-size:11px;border-left:2px solid;padding-left:10px}.error{color:#ff8e78;border-color:#ff6648}.success{color:#83c998;border-color:#55b673} +.builder{display:grid;grid-template-columns:1fr 340px;gap:24px}.slots{display:grid;grid-template-columns:1fr 1fr;gap:14px}.slot{text-align:left;min-height:160px;background:#141718;border:1px solid #303536;color:#eee;cursor:pointer}.slot:hover{border-color:#f6a821}.stats{height:max-content}.stats div{display:flex;justify-content:space-between;font-size:11px;padding:14px 0;border-bottom:1px solid #303536} +.admin-layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:12px;align-items:start}.admin-nav{position:sticky;top:88px;display:flex;flex-direction:column;background:#111415;border:1px solid #2d3233;padding:8px 0}.admin-nav a{color:#9ca0a1;border-bottom:1px solid #292d2f;padding:13px 14px;font-size:10px}.admin-nav a.active{color:#f6a821;border-left:2px solid #f6a821}.table{overflow:auto}.table-row{display:grid;grid-template-columns:1.5fr repeat(3,1fr) .35fr;gap:10px;align-items:center;border-top:1px solid #2b3031;padding:11px 14px;font-size:11px;min-width:700px}.table-row.head{font-size:8px;color:#6f7577}.table-row button,.card button:not(.primary):not(.secondary){background:none;border:0;border-bottom:1px solid transparent;color:#f6a821;font-size:9px;cursor:pointer}.table-row button:hover{border-bottom-color:#f6a821}.primary,.secondary,.danger{display:inline-block;text-decoration:none}.auth-page{min-height:calc(100vh - 76px)}hr{border:0;border-top:1px solid #303536;margin:26px 0}button:disabled,fieldset[disabled]{cursor:wait}button:not(:disabled):active,a.primary:active,a.secondary:active{transform:translateY(2px) scale(.985);filter:brightness(.9)} +@media(max-width:900px){.site-header nav{display:none}.cards,.builder,.admin-layout,.form-grid{grid-template-columns:1fr}.slots{grid-template-columns:1fr}.admin-nav{position:static;display:grid;grid-template-columns:1fr 1fr}.page-head{align-items:flex-start;gap:22px}}@media(max-width:560px){.page-head{flex-direction:column}.site-header .account>a{display:none}.cards{grid-template-columns:1fr}} + +/* SHD admin operations dashboard */ +.admin-page{width:100%;min-height:calc(100vh - 76px);padding:18px clamp(14px,2vw,32px) 40px;background:linear-gradient(135deg,#101314aa 0,transparent 38%)}.admin-command{height:auto;min-height:76px;position:relative;top:auto;display:flex;justify-content:space-between;align-items:center;padding:0 0 16px;background:transparent;border-bottom:1px solid #303536}.command-title h1{margin:4px 0 2px;font-size:27px;letter-spacing:1px}.command-title>p:last-child{margin:0;color:#697073;font-size:9px;letter-spacing:1.1px}.command-status{display:flex;gap:7px}.command-status>div{position:relative;min-width:105px;height:47px;padding:9px 12px;background:#111415;border:1px solid #303536;display:grid;align-content:center}.command-status span{font-size:7px;letter-spacing:1.3px;color:#6f7678}.command-status strong{font-size:10px;letter-spacing:1px;margin-top:3px}.command-status i{position:absolute;right:9px;top:9px;width:6px;height:6px;border-radius:50%;background:#68bd82;box-shadow:0 0 8px #68bd82}.admin-dashboard{display:grid;grid-template-columns:210px minmax(0,1fr);gap:12px;margin-top:12px}.admin-sidebar{position:sticky;top:88px;min-height:520px;height:calc(100vh - 118px);display:flex;flex-direction:column;background:#0f1213;border:1px solid #2c3132}.sidebar-label{height:43px;padding:10px 12px;border-bottom:1px solid #2c3132;display:flex;justify-content:space-between;align-items:center}.sidebar-label span,.sidebar-label b{font-size:7px;letter-spacing:1.3px}.sidebar-label span{color:#8d9395}.sidebar-label b{color:#505759}.admin-sidebar nav{display:flex;flex-direction:column;margin:0;height:auto}.admin-sidebar nav a{display:grid;grid-template-columns:33px 1fr auto;align-items:center;min-height:49px;padding:0 12px 0 0;color:#858c8e;border:0;border-bottom:1px solid #272c2d;font-size:10px}.admin-sidebar nav a small{height:100%;display:grid;place-items:center;background:#141819;color:#535a5c;font-size:7px;letter-spacing:.6px}.admin-sidebar nav a span{padding-left:11px}.admin-sidebar nav a b{font-size:17px;color:#f6a821}.admin-sidebar nav a em{font-style:normal;font-size:6px;color:#555c5e;letter-spacing:1px}.admin-sidebar nav a:hover,.admin-sidebar nav a.active{color:#fff;background:#151819}.admin-sidebar nav a.active{box-shadow:inset 2px 0 #f6a821}.admin-sidebar nav a.active small{background:#2b2111;color:#f6a821}.admin-sidebar nav a.pending{opacity:.5;pointer-events:none}.sidebar-foot{margin-top:auto;padding:14px;border-top:1px solid #2c3132;display:grid}.sidebar-foot span{font-size:7px;color:#5e6668;letter-spacing:1.3px}.sidebar-foot strong{font-size:14px;color:#f6a821;margin:4px 0}.sidebar-foot small{font-size:8px;color:#596164}.admin-content{min-width:0}.module-toolbar{min-height:63px;display:flex;align-items:center;justify-content:space-between;padding:0 2px 12px;border-bottom:1px solid #303536}.module-toolbar h2{font-size:19px;letter-spacing:.4px;margin:3px 0}.module-toolbar p{margin:0}.module-toolbar>div>p:last-child{font-size:9px;color:#71787a}.module-toolbar>div:last-child{display:flex;gap:8px}.module-toolbar .primary,.module-toolbar .secondary{height:40px;padding:0 15px}.module-kpis{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:8px;margin:10px 0}.module-kpis>div{position:relative;min-height:70px;padding:11px 13px;background:#111415;border:1px solid #2d3233;border-top:2px solid #3a4041;display:grid}.module-kpis>div:first-child{border-top-color:#f6a821}.module-kpis span{font-size:7px;letter-spacing:1.3px;color:#737a7c}.module-kpis strong{font-size:23px;line-height:1.1;margin:3px 0}.module-kpis small{font-size:7px;color:#555d5f}.dashboard-editor{margin:10px 0;background:#131617;border:1px solid #4a5051;border-top:2px solid #f6a821;padding:14px 16px}.editor-title{display:flex;justify-content:space-between;align-items:start;padding-bottom:9px;border-bottom:1px solid #303536}.editor-title h3{font-size:16px;margin:3px 0}.editor-title button{border:0;background:transparent;color:#7e8587;font-size:24px;line-height:1}.dashboard-editor .form-grid{grid-template-columns:repeat(5,minmax(0,1fr));gap:9px}.dashboard-editor .field{margin:8px 0}.dashboard-editor .field input,.dashboard-editor .field select,.dashboard-editor .field textarea{padding:8px 9px;margin-top:5px;font-size:10px}.range-field>div,.uptime-field>div{display:grid;grid-template-columns:1fr 1fr;gap:5px}.dashboard-table{border:1px solid #2d3233;background:#111415;overflow:auto}.dashboard-row{min-width:820px;display:grid;grid-template-columns:1.5fr 1fr .75fr .85fr .6fr .3fr;align-items:center;gap:10px;min-height:46px;padding:7px 13px;border-top:1px solid #272c2d;font-size:10px}.dashboard-row:hover:not(.table-heading){background:#15191a}.dashboard-row>div{display:grid;gap:3px}.dashboard-row>div small{font-size:7px;color:#60686a}.dashboard-row>span{width:max-content}.dashboard-row>button{justify-self:end;border:0;border-bottom:1px solid transparent;background:transparent;color:#f6a821;font-size:8px;font-weight:bold;padding:5px;cursor:pointer}.dashboard-row>button:hover{border-color:#f6a821}.table-heading{min-height:32px;border-top:0;background:#0d1011;color:#697174;font-size:7px;letter-spacing:1.2px}.state-dot{font-size:7px;letter-spacing:.8px;color:#75c08c}.state-dot:before{content:"";display:inline-block;width:5px;height:5px;margin-right:6px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor}.dashboard-row.archived{opacity:.45}.type-offensive,.type-defensive{padding:4px 6px;border:1px solid}.type-offensive{color:#df8069;border-color:#70463c}.type-defensive{color:#70b9d6;border-color:#365e6e}.tier-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-top:10px}.tier-card{min-width:0;padding:8px 11px 11px;border:1px solid #343a3b;background:#101314}.tier-card legend{padding:0 7px;color:#f6a821;font-size:8px;font-weight:bold;letter-spacing:1.3px}.trigger-list{max-height:130px;overflow:auto;border:1px solid #292f30}.trigger-list>div{display:grid;grid-template-columns:1fr auto;align-items:center;min-height:33px;padding:4px 7px;border-top:1px solid #252a2b;background:#121516}.trigger-list label{display:flex;align-items:center;gap:7px;font-size:9px}.trigger-list input{width:auto;margin:0;accent-color:#f6a821}.trigger-list button,.effects-head button{border:0;background:transparent;color:#f6a821;font-size:7px}.effects-head{display:flex;justify-content:space-between;align-items:center;margin-top:10px}.talent-effect{display:grid;grid-template-columns:1fr 90px 27px;gap:5px;margin-top:5px}.talent-effect select,.talent-effect input{min-width:0;padding:7px;margin:0;font-size:9px}.talent-effect button{border:1px solid #563734;background:#1d1212;color:#da746c}.talent-identity{grid-template-columns:2fr 1fr!important}.uptime-field>span{display:flex;align-items:center;gap:7px}.uptime-field>span input{width:auto;margin:0;accent-color:#f6a821}.script-note{align-self:end;margin:8px 0;padding:9px;border:1px dashed #394041;display:grid}.script-note b{font-size:7px;color:#8e9597;letter-spacing:1.2px}.script-note span{font-size:8px;color:#5d6567;margin-top:4px}.configured{color:#79bd8d;font-size:8px} +@media(max-width:900px){.admin-command{align-items:flex-start}.command-status>div:nth-child(n+2){display:none}.admin-dashboard{grid-template-columns:1fr}.admin-sidebar{position:static;height:auto;min-height:0}.admin-sidebar nav{display:grid;grid-template-columns:1fr 1fr}.sidebar-foot{display:none}.module-kpis{grid-template-columns:1fr 1fr}.dashboard-editor .form-grid{grid-template-columns:1fr 1fr}.tier-grid{grid-template-columns:1fr}}@media(max-width:560px){.admin-command,.module-toolbar{flex-direction:column;align-items:flex-start;gap:12px}.module-toolbar>div:last-child{width:100%}.module-toolbar button{flex:1}.module-kpis{grid-template-columns:1fr 1fr}.dashboard-editor .form-grid{grid-template-columns:1fr}.talent-identity{grid-template-columns:1fr!important}} + diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..6a2bb58 --- /dev/null +++ b/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..5d3ac3c --- /dev/null +++ b/src/lib/components/AppShell.svelte @@ -0,0 +1,42 @@ + + + + +{@render children()} diff --git a/src/lib/components/PendingForm.svelte b/src/lib/components/PendingForm.svelte new file mode 100644 index 0000000..253471e --- /dev/null +++ b/src/lib/components/PendingForm.svelte @@ -0,0 +1,11 @@ + +
{ if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}> +
{@render children(pending)}
+
+ + diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/server/auth/authorization.ts b/src/lib/server/auth/authorization.ts new file mode 100644 index 0000000..da7c310 --- /dev/null +++ b/src/lib/server/auth/authorization.ts @@ -0,0 +1,12 @@ +import type { AuthUser } from './session'; +import { error } from '@sveltejs/kit'; + +/** + * Every admin page, action, and endpoint must call this server-side. + * The capability-based permission checks will replace the broad admin role + * as the permission editor is introduced. + */ +export function requireAdmin(user: AuthUser | null): asserts user is AuthUser { + if (!user) error(401, 'Sign in required'); + if (user.role !== 'admin') error(403, 'Administrator permission required'); +} diff --git a/src/lib/server/auth/password.ts b/src/lib/server/auth/password.ts new file mode 100644 index 0000000..d26c126 --- /dev/null +++ b/src/lib/server/auth/password.ts @@ -0,0 +1,18 @@ +import { hash, verify, argon2id } from 'argon2'; + +/** + * Password hashes use the PHC string format, which embeds the unique salt, + * algorithm version, and work parameters alongside the derived hash. + */ +export function hashPassword(password: string): Promise { + return hash(password, { + type: argon2id, + memoryCost: 19_456, + timeCost: 2, + parallelism: 1 + }); +} + +export function verifyPassword(passwordHash: string, candidate: string): Promise { + return verify(passwordHash, candidate); +} diff --git a/src/lib/server/auth/session.ts b/src/lib/server/auth/session.ts new file mode 100644 index 0000000..da44fc1 --- /dev/null +++ b/src/lib/server/auth/session.ts @@ -0,0 +1,82 @@ +import { dev } from '$app/environment'; +import { getDatabase } from '$lib/server/database'; +import type { Cookies } from '@sveltejs/kit'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; + +const COOKIE_NAME = 'shd_session'; +const SESSION_SECONDS = 60 * 60 * 24 * 30; + +export type AuthUser = { + id: string; + username: string; + role: string; + discordLinked: boolean; +}; + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +export async function createSession(cookies: Cookies, userId: string): Promise { + const database = await getDatabase(); + const token = randomBytes(32).toString('base64url'); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_SECONDS * 1000); + const insert = await database.prepare( + 'INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at) VALUES (?, ?, ?, ?, ?)' + ); + + await insert.run( + randomUUID(), + userId, + hashToken(token), + expiresAt.toISOString(), + now.toISOString() + ); + + cookies.set(COOKIE_NAME, token, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: !dev, + maxAge: SESSION_SECONDS + }); +} + +export async function getSessionUser(cookies: Cookies): Promise { + const token = cookies.get(COOKIE_NAME); + if (!token) return null; + + const database = await getDatabase(); + const statement = await database.prepare(` + SELECT users.id, users.username, users.role, users.discord_id + FROM sessions + JOIN users ON users.id = sessions.user_id + WHERE sessions.token_hash = ? AND sessions.expires_at > ? + `); + const user = await statement.get(hashToken(token), new Date().toISOString()); + + if (!user) { + cookies.delete(COOKIE_NAME, { path: '/' }); + return null; + } + + return { + id: String(user.id), + username: String(user.username), + role: String(user.role), + discordLinked: typeof user.discord_id === 'string' + }; +} + +export async function destroySession(cookies: Cookies): Promise { + const token = cookies.get(COOKIE_NAME); + + if (token) { + const database = await getDatabase(); + const statement = await database.prepare('DELETE FROM sessions WHERE token_hash = ?'); + await statement.run(hashToken(token)); + } + + cookies.delete(COOKIE_NAME, { path: '/' }); +} diff --git a/src/lib/server/database.ts b/src/lib/server/database.ts new file mode 100644 index 0000000..da38863 --- /dev/null +++ b/src/lib/server/database.ts @@ -0,0 +1,21 @@ +import { connect, type Database } from '@tursodatabase/database'; +import { env } from '$env/dynamic/private'; +import { resolve } from 'node:path'; + +let databasePromise: Promise | undefined; + +/** + * Returns the process-wide embedded Turso connection. + * + * This module lives under $lib/server so SvelteKit prevents it from being + * imported into browser bundles. + */ +export function getDatabase(): Promise { + const configuredPath = env.TDR_DATABASE_PATH || './data/shd-build.db'; + + if (!databasePromise) { + databasePromise = connect(resolve(configuredPath)); + } + + return databasePromise; +} diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..966b5f0 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,6 @@ +import { getSessionUser } from '$lib/server/auth/session'; +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ cookies }) => ({ + user: await getSessionUser(cookies) +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..00f57b8 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,11 @@ + + + + + + +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..4ce9a34 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,15 @@ + +SHD.build — Division Resurgence Loadouts +
+
+

SHD LOADOUT NETWORK

BUILD SMARTER.
HIT HARDER.

Plan every stat. Test every synergy. Share the loadout that gets your agent through the toughest fights in Manhattan.

2,847PUBLIC BUILDS
14.2KAGENTS
8,931RATINGS
+
FEATURED LOADOUT01 / 03
SHD
{#each ['MASK','VEST','PACK','GLOVES'] as label,i}
{label}{['42','38','39','6.5%'][i]}
{/each}
META // PVESTRIKER CRITICAL RUSHBY AGENTNOVA
+
+ +
diff --git a/src/routes/account/+page.server.ts b/src/routes/account/+page.server.ts new file mode 100644 index 0000000..bb3df59 --- /dev/null +++ b/src/routes/account/+page.server.ts @@ -0,0 +1,47 @@ +import { createSession, getSessionUser } from '$lib/server/auth/session'; +import { hashPassword, verifyPassword } from '$lib/server/auth/password'; +import { getDatabase } from '$lib/server/database'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ cookies, url }) => { + const user = await getSessionUser(cookies); + if (!user) redirect(303, '/sign-in'); + const database = await getDatabase(); + const [builds, guides, favorites, ratings] = await Promise.all([ + (await database.prepare('SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? ORDER BY updated_at DESC')).all(user.id), + (await database.prepare("SELECT id, title, slug, visibility, updated_at FROM builds WHERE owner_id = ? AND guide_body IS NOT NULL AND length(trim(guide_body)) > 0 ORDER BY updated_at DESC")).all(user.id), + (await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author FROM favorites JOIN builds ON builds.id = favorites.build_id JOIN users ON users.id = builds.owner_id WHERE favorites.user_id = ? ORDER BY favorites.created_at DESC')).all(user.id), + (await database.prepare('SELECT builds.id, builds.title, builds.slug, users.username AS author, ratings.value FROM ratings JOIN builds ON builds.id = ratings.build_id JOIN users ON users.id = builds.owner_id WHERE ratings.user_id = ? ORDER BY builds.updated_at DESC')).all(user.id) + ]); + return { account: { builds, guides, favorites, ratings }, chooseUsername: url.searchParams.has('choose_username'), discordLinked: url.searchParams.has('discord_linked'), oauthError: url.searchParams.has('oauth_error') }; +}; + +export const actions: Actions = { + changeUsername: async ({ request, cookies }) => { + const user = await getSessionUser(cookies); + if (!user) redirect(303, '/sign-in'); + const username = String((await request.formData()).get('username') ?? '').normalize('NFKC').trim(); + if (username.length < 3 || username.length > 24 || !/^[\p{L}\p{N}_-]+$/u.test(username)) return fail(400, { usernameError: 'Use 3–24 letters, numbers, underscores, or hyphens.' }); + if (['admin','administrator','moderator','shd','support'].includes(username.toLowerCase())) return fail(400, { usernameError: 'That username is reserved.' }); + const database = await getDatabase(); + if (await (await database.prepare('SELECT id FROM users WHERE username = ? COLLATE NOCASE AND id <> ?')).get(username, user.id)) return fail(409, { usernameError: 'That username is already in use.' }); + await (await database.prepare('UPDATE users SET username = ? WHERE id = ?')).run(username, user.id); + return { usernameChanged: true }; + }, + changePassword: async ({ request, cookies }) => { + const user = await getSessionUser(cookies); + if (!user) redirect(303, '/sign-in'); + const form = await request.formData(); + const current = String(form.get('currentPassword') ?? ''), next = String(form.get('newPassword') ?? ''), confirm = String(form.get('confirmPassword') ?? ''); + if (next.length < 12 || next.length > 128) return fail(400, { passwordError: 'Use between 12 and 128 characters.' }); + if (next !== confirm) return fail(400, { passwordError: 'The new passwords do not match.' }); + const database = await getDatabase(); + const credential = await (await database.prepare('SELECT password_hash FROM users WHERE id = ?')).get(user.id); + if (typeof credential?.password_hash !== 'string' || !(await verifyPassword(credential.password_hash, current))) return fail(400, { passwordError: 'Your current password is incorrect.' }); + await (await database.prepare('UPDATE users SET password_hash = ? WHERE id = ?')).run(await hashPassword(next), user.id); + await (await database.prepare('DELETE FROM sessions WHERE user_id = ?')).run(user.id); + await createSession(cookies, user.id); + return { passwordChanged: true }; + } +}; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte new file mode 100644 index 0000000..041037b --- /dev/null +++ b/src/routes/account/+page.svelte @@ -0,0 +1,8 @@ + +{user.username} — SHD.build +

AGENT PROFILE

{user.username}

{user.role.toUpperCase()}
{data.account.builds.length} builds · {data.account.guides.length} guides · {data.account.favorites.length} favorites
+{#if data.chooseUsername}

Choose the unique SHD.build username other agents will see.

{/if}{#if data.discordLinked}

Discord account linked successfully.

{/if}{#if data.oauthError}

Discord linking could not be completed.

{/if} +
+

LOADOUT ARCHIVE

My builds

NEW BUILD +
{#if data.account.builds.length}{#each data.account.builds as build}
{build.title}{build.visibility}{new Date(String(build.updated_at)).toLocaleDateString()}
{/each}{:else}

Your saved and published loadouts will appear here.

{/if}

Favorites and ratings

{data.account.favorites.length} favorited · {data.account.ratings.length} rated

+ +
diff --git a/src/routes/admin/+layout.server.ts b/src/routes/admin/+layout.server.ts new file mode 100644 index 0000000..f15d54c --- /dev/null +++ b/src/routes/admin/+layout.server.ts @@ -0,0 +1,9 @@ +import { getSessionUser } from '$lib/server/auth/session'; +import { redirect } from '@sveltejs/kit'; +import type { LayoutServerLoad } from './$types'; +export const load: LayoutServerLoad = async ({ cookies }) => { + const user = await getSessionUser(cookies); + if (!user) redirect(303, '/sign-in'); + if (user.role !== 'admin') redirect(303, '/account'); + return {}; +}; diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte new file mode 100644 index 0000000..9bf37ff --- /dev/null +++ b/src/routes/admin/+layout.svelte @@ -0,0 +1,31 @@ + + +
+
+

SHD DATA OPERATIONS

ADMIN CONSOLE

Embedded game-data control surface

+
DATABASEONLINE
ACCESSADMIN
ENVIRONMENTLOCAL
+
+
+ +
{@render children()}
+
+
diff --git a/src/routes/admin/+page.server.ts b/src/routes/admin/+page.server.ts new file mode 100644 index 0000000..75e333c --- /dev/null +++ b/src/routes/admin/+page.server.ts @@ -0,0 +1,2 @@ +import { redirect } from '@sveltejs/kit'; +export const load = () => redirect(303, '/admin/stats'); diff --git a/src/routes/admin/stats/+page.server.ts b/src/routes/admin/stats/+page.server.ts new file mode 100644 index 0000000..3c20901 --- /dev/null +++ b/src/routes/admin/stats/+page.server.ts @@ -0,0 +1,35 @@ +import { getSessionUser } from '$lib/server/auth/session'; +import { requireAdmin } from '$lib/server/auth/authorization'; +import { getDatabase } from '$lib/server/database'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const database = await getDatabase(); + return { stats: await (await database.prepare(`SELECT * FROM stat_attributes ORDER BY active DESC, name COLLATE NOCASE`)).all() }; +}; +export const actions: Actions = { + save: async ({ request, cookies }) => { + requireAdmin(await getSessionUser(cookies)); + const form = await request.formData(); + const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim(); + const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? 'flat'); + if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' }); + if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' }); + const number = (key: string) => { const raw = String(form.get(key) ?? '').trim(); if (!raw) return null; const value = Number(raw); if (!Number.isFinite(value) || value < 0) throw new Error(); return value; }; + let values: Array; + try { values = ['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax','capValue'].map(number); } catch { return fail(400, { error: 'Ranges must be positive numbers.' }); } + for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' }); + const database = await getDatabase(); + if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND id <> ?')).get(name, id || 'new')) return fail(409, { error: 'That attribute already exists.' }); + const now = new Date().toISOString(); + if (id) await (await database.prepare(`UPDATE stat_attributes SET name=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,updated_at=? WHERE id=?`)).run(name,type,category,calculation,unit,...values,now,id); + else await (await database.prepare(`INSERT INTO stat_attributes(id,name,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(randomUUID(),name,type,category,calculation,unit,...values,now,now); + return { saved: true }; + }, + archive: async ({ request, cookies }) => { + requireAdmin(await getSessionUser(cookies)); const id = String((await request.formData()).get('id') ?? ''); + await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id); return { archived: true }; + } +}; diff --git a/src/routes/admin/stats/+page.svelte b/src/routes/admin/stats/+page.svelte new file mode 100644 index 0000000..f7b2c69 --- /dev/null +++ b/src/routes/admin/stats/+page.svelte @@ -0,0 +1,12 @@ + +Stats & attributes — SHD.build admin +

ATTRIBUTE REGISTRY

STATS & ATTRIBUTES

Builder values, hidden calculation targets, source ranges, and caps.

+
TOTAL RECORDS{data.stats.length}All definitions
ACTIVE{data.stats.filter((item)=>Number(item.active)===1).length}Available to systems
OFFENSIVE{data.stats.filter((item)=>item.type==='Offensive').length}Damage-oriented
DEFENSIVE{data.stats.filter((item)=>item.type==='Defensive').length}Survival-oriented
+{#if editing}

{editing.id?'MODIFY RECORD':'NEW RECORD'}

{editing.id?editing.name:'ATTRIBUTE DEFINITION'}

{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}
{source[1]} RANGE · OPTIONAL
{/each}
{#if form?.error}

{form.error}

{/if}
{/if} +
ATTRIBUTECLASSIFICATIONCATEGORYCALCULATIONSTATE
{#each data.stats as stat}
{stat.name}{stat.unit}
{stat.type}{stat.category}{stat.calculation_type}{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}
{/each}
diff --git a/src/routes/admin/talents/+page.server.ts b/src/routes/admin/talents/+page.server.ts new file mode 100644 index 0000000..7797036 --- /dev/null +++ b/src/routes/admin/talents/+page.server.ts @@ -0,0 +1,59 @@ +import { getSessionUser } from '$lib/server/auth/session'; +import { requireAdmin } from '$lib/server/auth/authorization'; +import { getDatabase } from '$lib/server/database'; +import { fail } from '@sveltejs/kit'; +import { randomUUID } from 'node:crypto'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const db = await getDatabase(); + const [talents, tiers, triggers, links, effects, stats] = await Promise.all([ + (await db.prepare('SELECT * FROM talents ORDER BY active DESC,name COLLATE NOCASE')).all(), + (await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(), + (await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(), + (await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(), + (await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(), + (await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all() + ]); + return { talents: talents.map((talent) => ({...talent, tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({...tier, triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)), effects: effects.filter((effect) => effect.talent_tier_id === tier.id)}))})), triggers, stats }; +}; +const parseEffects = (raw: FormDataEntryValue | null) => { + const value = JSON.parse(String(raw ?? '[]')) as Array<{statId:string;value:string|number}>; + if (!Array.isArray(value)) throw new Error(); + return value.map((effect) => ({statId:String(effect.statId),value:Number(effect.value)})); +}; +export const actions: Actions = { + saveTrigger: async ({request,cookies}) => { + requireAdmin(await getSessionUser(cookies)); const form=await request.formData(); + const id=String(form.get('id')??''), name=String(form.get('name')??'').trim(), description=String(form.get('description')??'').trim(), uptime=form.get('supportsUptime')==='on'; + const min=uptime?Number(form.get('minUptime')):null,max=uptime?Number(form.get('maxUptime')):null; + if(name.length<2||!description)return fail(400,{triggerError:'A trigger name and description are required.'}); + if(uptime&&(!Number.isFinite(min)||!Number.isFinite(max)||Number(min)<0||Number(max)>100||Number(min)>Number(max)))return fail(400,{triggerError:'Uptime must be a valid 0–100% range.'}); + const db=await getDatabase(); if(await(await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name,id||'new'))return fail(409,{triggerError:'That trigger already exists.'}); + const now=new Date().toISOString(); + if(id)await(await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name,description,uptime?1:0,min,max,now,id); + else await(await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(randomUUID(),name,description,uptime?1:0,min,max,now,now); + return {triggerSaved:true}; + }, + saveTalent: async ({request,cookies}) => { + requireAdmin(await getSessionUser(cookies)); const form=await request.formData(); + const id=String(form.get('id')??''),name=String(form.get('name')??'').trim(),category=String(form.get('category')??''); + const descriptions={I:String(form.get('tierIDescription')??'').trim(),II:String(form.get('tierIIDescription')??'').trim()}; + const triggerIds={I:form.getAll('tierITriggerIds').map(String),II:form.getAll('tierIITriggerIds').map(String)}; + let tierEffects:{I:Array<{statId:string;value:number}>;II:Array<{statId:string;value:number}>}; + try{tierEffects={I:parseEffects(form.get('tierIEffects')),II:parseEffects(form.get('tierIIEffects'))};}catch{return fail(400,{talentError:'Effects could not be read.'});} + if(name.length<2||!['Weapon','Backpack','Vest'].includes(category))return fail(400,{talentError:'Provide a valid talent name and category.'}); + for(const tier of ['I','II'] as const)if(!descriptions[tier]||!triggerIds[tier].length||!tierEffects[tier].length||tierEffects[tier].some((effect)=>!effect.statId||!Number.isFinite(effect.value)))return fail(400,{talentError:`Tier ${tier} requires a description, trigger, and valid attribute effects.`}); + const db=await getDatabase(); if(await(await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name,category,id||'new'))return fail(409,{talentError:'That talent already exists in this category.'}); + const talentId=id||randomUUID(),now=new Date().toISOString(); + if(id)await(await db.prepare('UPDATE talents SET name=?,category=?,updated_at=? WHERE id=?')).run(name,category,now,id);else await(await db.prepare('INSERT INTO talents(id,name,category,created_at,updated_at) VALUES(?,?,?,?,?)')).run(talentId,name,category,now,now); + for(const tier of ['I','II'] as const){ + await(await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(),talentId,tier,descriptions[tier],'','',now,now); + const tierRow=await(await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId,tier); const tierId=String(tierRow?.id); + await(await db.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId); await(await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId); + for(const triggerId of triggerIds[tier])await(await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId,triggerId); + for(const effect of tierEffects[tier])await(await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(),tierId,effect.statId,effect.value,now); + } + return {talentSaved:true}; + } +}; diff --git a/src/routes/admin/talents/+page.svelte b/src/routes/admin/talents/+page.svelte new file mode 100644 index 0000000..d5bc4a4 --- /dev/null +++ b/src/routes/admin/talents/+page.svelte @@ -0,0 +1,20 @@ + +Talents & triggers — SHD.build admin +

TALENT REGISTRY

TALENTS & TRIGGERS

Tier behavior, reusable activation conditions, uptime, and attribute effects.

+
TALENTS{data.talents.length}Registered definitions
TRIGGERS{data.triggers.length}Reusable conditions
WEAPON{data.talents.filter((item)=>item.category==='Weapon').length}Weapon talents
GEAR{data.talents.filter((item)=>item.category!=='Weapon').length}Vest and backpack
+{#if trigger}

{trigger.id?'MODIFY TRIGGER':'NEW TRIGGER'}

{trigger.id?trigger.name:'TRIGGER DEFINITION'}

CALCULATION SCRIPTReserved for the future calculation engine

{#if form?.triggerError}

{form.triggerError}

{/if}
{/if} +{#if talent}

{talent.id?'MODIFY TALENT':'NEW TALENT'}

{talent.id?talent.name:'TALENT DEFINITION'}

{#each ['I','II'] as tier}
TIER {tier}

ACTIVATION TRIGGERS

{#each data.triggers.filter((item)=>Number(item.active)===1) as item}
{/each}

ATTRIBUTE EFFECTS

{#each talent[tier].effects as effect,index}
{/each}
{/each}
{#if form?.talentError}

{form.talentError}

{/if}
{/if} +
TALENTCATEGORYTIER ITIER IISTATE
{#each data.talents as item}
{item.name}Tiered talent
{item.category}{configured(item.tiers,'I')?'CONFIGURED':'MISSING'}{configured(item.tiers,'II')?'CONFIGURED':'MISSING'}ACTIVE
{/each}
diff --git a/src/routes/api/health/database/+server.ts b/src/routes/api/health/database/+server.ts new file mode 100644 index 0000000..d37016b --- /dev/null +++ b/src/routes/api/health/database/+server.ts @@ -0,0 +1,13 @@ +import { getDatabase } from '$lib/server/database'; +import { json } from '@sveltejs/kit'; + +export async function GET() { + const database = await getDatabase(); + const statement = await database.prepare('SELECT 1 AS healthy'); + const result = await statement.get(); + + return json({ + status: result?.healthy === 1 ? 'ok' : 'error', + engine: '@tursodatabase/database' + }); +} diff --git a/src/routes/auth/discord/+server.ts b/src/routes/auth/discord/+server.ts new file mode 100644 index 0000000..2eac91f --- /dev/null +++ b/src/routes/auth/discord/+server.ts @@ -0,0 +1,58 @@ +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; +import { getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { error, redirect } from '@sveltejs/kit'; +import { createHash, randomBytes } from 'node:crypto'; +import type { RequestHandler } from './$types'; + +const STATE_COOKIE = 'shd_discord_oauth_state'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) { + error(503, 'Discord sign-in has not been configured yet.'); + } + + const state = randomBytes(32).toString('base64url'); + const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`; + const mode = url.searchParams.get('mode') === 'link' ? 'link' : 'signin'; + const user = mode === 'link' ? await getSessionUser(cookies) : null; + + if (mode === 'link' && !user) { + redirect(303, '/sign-in?oauth_error=link_requires_login'); + } + + const database = await getDatabase(); + const removeExpired = await database.prepare('DELETE FROM oauth_states WHERE expires_at <= ?'); + await removeExpired.run(new Date().toISOString()); + const insertState = await database.prepare( + 'INSERT INTO oauth_states (state_hash, provider, mode, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)' + ); + const now = new Date(); + await insertState.run( + createHash('sha256').update(state).digest('hex'), + 'discord', + mode, + user?.id ?? null, + new Date(now.getTime() + 10 * 60 * 1000).toISOString(), + now.toISOString() + ); + + cookies.set(STATE_COOKIE, state, { + path: '/auth/discord', + httpOnly: true, + sameSite: 'lax', + secure: !dev, + maxAge: 60 * 10 + }); + + const authorizeUrl = new URL('https://discord.com/oauth2/authorize'); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', env.DISCORD_CLIENT_ID); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', 'identify'); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('prompt', 'consent'); + + redirect(302, authorizeUrl.toString()); +}; diff --git a/src/routes/auth/discord/callback/+server.ts b/src/routes/auth/discord/callback/+server.ts new file mode 100644 index 0000000..72958e2 --- /dev/null +++ b/src/routes/auth/discord/callback/+server.ts @@ -0,0 +1,123 @@ +import { dev } from '$app/environment'; +import { env } from '$env/dynamic/private'; +import { createSession, getSessionUser } from '$lib/server/auth/session'; +import { getDatabase } from '$lib/server/database'; +import { redirect } from '@sveltejs/kit'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; +import type { RequestHandler } from './$types'; + +const STATE_COOKIE = 'shd_discord_oauth_state'; + +type DiscordToken = { access_token: string; token_type: string }; +type DiscordUser = { id: string; username: string; global_name: string | null }; + +function stateMatches(expected: string | undefined, received: string | null): boolean { + if (!expected || !received) return false; + const expectedBytes = Buffer.from(expected); + const receivedBytes = Buffer.from(received); + return expectedBytes.length === receivedBytes.length && timingSafeEqual(expectedBytes, receivedBytes); +} + +async function uniqueUsername(discordUser: DiscordUser): Promise { + const database = await getDatabase(); + const base = + (discordUser.global_name || discordUser.username) + .normalize('NFKC') + .replace(/[^\p{L}\p{N}_-]/gu, '') + .slice(0, 24) || 'Agent'; + const find = await database.prepare('SELECT 1 FROM users WHERE username = ? COLLATE NOCASE'); + + if (!(await find.get(base))) return base; + + const suffix = discordUser.id.slice(-6); + const candidate = `${base.slice(0, 17)}_${suffix}`; + if (!(await find.get(candidate))) return candidate; + + return `Agent_${randomUUID().slice(0, 8)}`; +} + +export const GET: RequestHandler = async ({ cookies, url, fetch }) => { + const expectedState = cookies.get(STATE_COOKIE); + cookies.delete(STATE_COOKIE, { path: '/auth/discord' }); + + if (url.searchParams.has('error')) { + redirect(303, '/sign-in?oauth_error=discord_denied'); + } + + const code = url.searchParams.get('code'); + if (!code || !stateMatches(expectedState, url.searchParams.get('state'))) { + redirect(303, '/sign-in?oauth_error=invalid_state'); + } + + const database = await getDatabase(); + const findState = await database.prepare(` + SELECT mode, user_id FROM oauth_states + WHERE state_hash = ? AND provider = 'discord' AND expires_at > ? + `); + const stateHash = createHash('sha256').update(String(url.searchParams.get('state'))).digest('hex'); + const oauthState = await findState.get(stateHash, new Date().toISOString()); + const deleteState = await database.prepare('DELETE FROM oauth_states WHERE state_hash = ?'); + await deleteState.run(stateHash); + if (!oauthState) redirect(303, '/sign-in?oauth_error=invalid_state'); + + if (!env.DISCORD_CLIENT_ID || !env.DISCORD_CLIENT_SECRET) { + redirect(303, '/sign-in?oauth_error=not_configured'); + } + + const redirectUri = env.DISCORD_REDIRECT_URI || `${url.origin}/auth/discord/callback`; + const tokenResponse = await fetch('https://discord.com/api/v10/oauth2/token', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: env.DISCORD_CLIENT_ID, + client_secret: env.DISCORD_CLIENT_SECRET, + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri + }) + }); + + if (!tokenResponse.ok) redirect(303, '/sign-in?oauth_error=token_exchange'); + const token = (await tokenResponse.json()) as DiscordToken; + + const userResponse = await fetch('https://discord.com/api/v10/users/@me', { + headers: { authorization: `${token.token_type} ${token.access_token}` } + }); + if (!userResponse.ok) redirect(303, '/sign-in?oauth_error=identity'); + const discordUser = (await userResponse.json()) as DiscordUser; + + if (oauthState.mode === 'link') { + const sessionUser = await getSessionUser(cookies); + if (!sessionUser || sessionUser.id !== String(oauthState.user_id)) { + redirect(303, '/account?oauth_error=link_session_changed'); + } + + const ownerStatement = await database.prepare('SELECT id FROM users WHERE discord_id = ?'); + const owner = await ownerStatement.get(discordUser.id); + if (owner && String(owner.id) !== sessionUser.id) { + redirect(303, '/account?oauth_error=discord_in_use'); + } + + const link = await database.prepare('UPDATE users SET discord_id = ? WHERE id = ?'); + await link.run(discordUser.id, sessionUser.id); + redirect(303, '/account?discord_linked=1'); + } + + const findUser = await database.prepare('SELECT id FROM users WHERE discord_id = ?'); + let user = await findUser.get(discordUser.id); + let created = false; + + if (!user) { + const username = await uniqueUsername(discordUser); + const id = randomUUID(); + const insertUser = await database.prepare( + 'INSERT INTO users (id, username, discord_id, role, created_at) VALUES (?, ?, ?, ?, ?)' + ); + await insertUser.run(id, username, discordUser.id, 'user', new Date().toISOString()); + user = { id }; + created = true; + } + + await createSession(cookies, String(user.id)); + redirect(303, created ? '/account?choose_username=1' : '/account'); +}; diff --git a/src/routes/builder/+page.svelte b/src/routes/builder/+page.svelte new file mode 100644 index 0000000..2e93581 --- /dev/null +++ b/src/routes/builder/+page.svelte @@ -0,0 +1,40 @@ + + +Build a loadout — SHD.build +
+

LOADOUT WORKBENCH

SAVED LOCALLY
+ {#if message}

{message}

{/if} +
+
{#each slots as slot}{/each}
+ +
+
diff --git a/src/routes/builds/+page.svelte b/src/routes/builds/+page.svelte new file mode 100644 index 0000000..3b25969 --- /dev/null +++ b/src/routes/builds/+page.svelte @@ -0,0 +1,9 @@ + +Discover builds — SHD.build +

FIELD-TESTED LOADOUTS

Discover builds

Browse, sort, rate, and copy community builds.

NEW BUILD +
{#each builds as build}
{build.mode} · ★ {build.rating}

{build.title}

BY {build.author.toUpperCase()}

{/each}
diff --git a/src/routes/guides/+page.svelte b/src/routes/guides/+page.svelte new file mode 100644 index 0000000..5b141fd --- /dev/null +++ b/src/routes/guides/+page.svelte @@ -0,0 +1,2 @@ +Build guides — SHD.build +

FIELD NOTES

Build guides

Detailed community strategies and loadout walkthroughs will live here.

Guides are coming next

The route is ready for guide listing, search, authorship, and rich build-linked articles.

diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts new file mode 100644 index 0000000..77beefd --- /dev/null +++ b/src/routes/sign-in/+page.server.ts @@ -0,0 +1,24 @@ +import { createSession, destroySession } from '$lib/server/auth/session'; +import { verifyPassword } from '$lib/server/auth/password'; +import { getDatabase } from '$lib/server/database'; +import { fail, redirect } from '@sveltejs/kit'; +import type { Actions } from './$types'; + +export const actions: Actions = { + login: async ({ request, cookies }) => { + const form = await request.formData(); + const username = String(form.get('username') ?? '').trim(); + const password = String(form.get('password') ?? ''); + if (!username || !password) return fail(400, { loginError: 'Enter your username and password.', username }); + const database = await getDatabase(); + const user = await (await database.prepare('SELECT id, password_hash FROM users WHERE username = ? COLLATE NOCASE')).get(username); + const valid = typeof user?.password_hash === 'string' && await verifyPassword(user.password_hash, password); + if (!valid) return fail(400, { loginError: 'Invalid username or password.', username }); + await createSession(cookies, String(user.id)); + redirect(303, '/account'); + }, + logout: async ({ cookies }) => { + await destroySession(cookies); + redirect(303, '/'); + } +}; diff --git a/src/routes/sign-in/+page.svelte b/src/routes/sign-in/+page.svelte new file mode 100644 index 0000000..78639fb --- /dev/null +++ b/src/routes/sign-in/+page.svelte @@ -0,0 +1,3 @@ + +Sign in — SHD.build +

AGENT ACCESS

WELCOME BACK

Sign in to publish builds, join discussions, and keep your loadouts synced.

◉ CONTINUE WITH DISCORD
OR USE YOUR ACCOUNT
{#if form?.loginError}

{form.loginError}

{/if}{#if data.oauthError}

{data.oauthError}

{/if}
diff --git a/src/routes/sign-in/+page.ts b/src/routes/sign-in/+page.ts new file mode 100644 index 0000000..0552e7d --- /dev/null +++ b/src/routes/sign-in/+page.ts @@ -0,0 +1,2 @@ +import type { PageLoad } from './$types'; +export const load: PageLoad = ({ url }) => ({ oauthError: url.searchParams.has('oauth_error') }); diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..c766a62 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,9 @@ + + + + + + + + SHD + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -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 +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..cb76b81 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,20 @@ +import adapter from '@sveltejs/adapter-auto'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes('node_modules') ? undefined : true + }, + + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter() + }) + ] +});