Checkpoint SHD dashboard design system
This commit is contained in:
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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==
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Protecting environment files with dotenvx..."
|
||||||
|
npm run env:stage
|
||||||
+33
@@ -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-*
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Generated
+1716
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
@@ -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 <encrypt|decrypt|stage>');
|
||||||
|
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.');
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
+54
File diff suppressed because one or more lines are too long
Vendored
+13
@@ -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 {};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { user, children }: {
|
||||||
|
user: { username: string; role: string } | null;
|
||||||
|
children: Snippet;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ href: '/builds', label: 'Discover' },
|
||||||
|
{ href: '/builder', label: 'Builder' },
|
||||||
|
{ href: '/guides', label: 'Guides' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="/" aria-label="SHD.build home">
|
||||||
|
<span class="brand-mark">SHD</span><span>SHD<b>.build</b></span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Main navigation">
|
||||||
|
{#each links as link}
|
||||||
|
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
||||||
|
{/each}
|
||||||
|
{#if user}
|
||||||
|
<a class:active={page.url.pathname.startsWith('/account')} href="/account">My Profile</a>
|
||||||
|
{/if}
|
||||||
|
{#if user?.role === 'admin'}
|
||||||
|
<a class:active={page.url.pathname.startsWith('/admin')} href="/admin/stats">Admin</a>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
{#if user}
|
||||||
|
<div class="account">
|
||||||
|
<a href="/account"><small>AGENT</small>{user.username}</a>
|
||||||
|
<form method="POST" action="/sign-in?/logout"><button>SIGN OUT →</button></form>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<a class="sign-in" href="/sign-in">SIGN IN →</a>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
let { action, method = 'POST', class: className = '', children }: { action: string; method?: 'POST'; class?: string; children: Snippet<[boolean]> } = $props();
|
||||||
|
let pending = $state(false);
|
||||||
|
</script>
|
||||||
|
<form {action} {method} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
|
||||||
|
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>fieldset{border:0;padding:0;margin:0;min-width:0}fieldset[aria-busy=true]{opacity:.7}</style>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// place files you want to import through the `$lib` alias in this folder.
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
return hash(password, {
|
||||||
|
type: argon2id,
|
||||||
|
memoryCost: 19_456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(passwordHash: string, candidate: string): Promise<boolean> {
|
||||||
|
return verify(passwordHash, candidate);
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<AuthUser | null> {
|
||||||
|
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<void> {
|
||||||
|
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: '/' });
|
||||||
|
}
|
||||||
@@ -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<Database> | 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<Database> {
|
||||||
|
const configuredPath = env.TDR_DATABASE_PATH || './data/shd-build.db';
|
||||||
|
|
||||||
|
if (!databasePromise) {
|
||||||
|
databasePromise = connect(resolve(configuredPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
return databasePromise;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
import AppShell from '$lib/components/AppShell.svelte';
|
||||||
|
let { data, children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<AppShell user={data.user}>{@render children()}</AppShell>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const builds = [
|
||||||
|
{ title: 'Striker Critical Rush', author: 'AgentNova', mode: 'PVE', rating: 4.9, votes: 128, tags: ['AR', 'Critical', 'Solo'], color: '#f6a821' },
|
||||||
|
{ title: 'Ironclad Vanguard', author: 'KelsoMain', mode: 'PVP', rating: 4.8, votes: 94, tags: ['Tank', 'Shield', 'Team'], color: '#61b7d7' },
|
||||||
|
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7, votes: 76, tags: ['Skill', 'Status', 'Support'], color: '#d36b5c' }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
<svelte:head><title>SHD.build — Division Resurgence Loadouts</title><meta name="description" content="Create, optimize, and share loadouts for The Division Resurgence." /></svelte:head>
|
||||||
|
<main>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-copy"><p class="eyebrow"><span></span> SHD LOADOUT NETWORK</p><h1>BUILD SMARTER.<br /><em>HIT HARDER.</em></h1><p class="lede">Plan every stat. Test every synergy. Share the loadout that gets your agent through the toughest fights in Manhattan.</p><div class="hero-actions"><a class="primary" href="/builder">START A BUILD <span>+</span></a><a class="secondary" href="/builds">EXPLORE BUILDS ↓</a></div><div class="metrics"><div><strong>2,847</strong><span>PUBLIC BUILDS</span></div><div><strong>14.2K</strong><span>AGENTS</span></div><div><strong>8,931</strong><span>RATINGS</span></div></div></div>
|
||||||
|
<div class="loadout-preview"><div class="scan-lines"></div><div class="preview-top"><span>FEATURED LOADOUT</span><b>01 / 03</b></div><div class="agent"><div class="orbit orbit-one"></div><div class="orbit orbit-two"></div><div class="agent-shape"><span>SHD</span></div>{#each ['MASK','VEST','PACK','GLOVES'] as label,i}<div class="gear-node n-{i}"><small>{label}</small><b>{['42','38','39','6.5%'][i]}</b></div>{/each}</div><div class="preview-name"><small>META // PVE</small><strong>STRIKER CRITICAL RUSH</strong><span>BY AGENTNOVA</span></div></div>
|
||||||
|
</section>
|
||||||
|
<section class="featured"><div class="section-heading"><div><p class="eyebrow"><span></span> FIELD-TESTED</p><h2>FEATURED BUILDS</h2></div><a href="/builds">VIEW ALL BUILDS →</a></div><div class="build-grid">{#each builds as build}<article style={`--build-color:${build.color}`}><div class="card-top"><span>{build.mode}</span><span>★ {build.rating}</span></div><div class="weapon-art"><div></div><span>SHD LOADOUT</span></div><div class="card-body"><p>COMMUNITY BUILD</p><h3>{build.title}</h3><div class="tags">{#each build.tags as tag}<span>{tag}</span>{/each}</div><div class="author"><span class="avatar">{build.author.slice(0,1)}</span><span>BY <strong>{build.author}</strong></span><span>{build.votes} RATINGS</span></div></div></article>{/each}</div></section>
|
||||||
|
</main>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">let { data, form } = $props(); const user = $derived(data.user!);</script>
|
||||||
|
<svelte:head><title>{user.username} — SHD.build</title></svelte:head>
|
||||||
|
<main class="page"><div class="page-head"><div><p class="eyebrow">AGENT PROFILE</p><h1>{user.username}</h1><span class="accent">{user.role.toUpperCase()}</span></div><div><b>{data.account.builds.length}</b> builds · <b>{data.account.guides.length}</b> guides · <b>{data.account.favorites.length}</b> favorites</div></div>
|
||||||
|
{#if data.chooseUsername}<p class="notice">Choose the unique SHD.build username other agents will see.</p>{/if}{#if data.discordLinked}<p class="success">Discord account linked successfully.</p>{/if}{#if data.oauthError}<p class="error">Discord linking could not be completed.</p>{/if}
|
||||||
|
<div class="grid" style="grid-template-columns:1fr 380px">
|
||||||
|
<section class="grid"><div class="panel"><div class="page-head"><div><p class="eyebrow">LOADOUT ARCHIVE</p><h2>My builds</h2></div><a class="secondary" href="/builder">NEW BUILD +</a></div>{#if data.account.builds.length}{#each data.account.builds as build}<div class="table-row"><b>{build.title}</b><span>{build.visibility}</span><span>{new Date(String(build.updated_at)).toLocaleDateString()}</span><span></span><button>OPEN</button></div>{/each}{:else}<p class="muted">Your saved and published loadouts will appear here.</p>{/if}</div><div class="panel"><h2>Favorites and ratings</h2><p class="muted">{data.account.favorites.length} favorited · {data.account.ratings.length} rated</p></div></section>
|
||||||
|
<aside class="panel"><p class="eyebrow">ACCOUNT SETTINGS</p><h2>Username</h2><form method="POST" action="?/changeUsername"><label class="field">PUBLIC USERNAME<input name="username" value={user.username} required /></label>{#if form?.usernameError}<p class="error">{form.usernameError}</p>{/if}<div class="form-actions"><button class="primary">UPDATE</button></div></form><hr /><h2>Sign-in methods</h2><a class="secondary" href="/auth/discord?mode=link">LINK DISCORD</a><hr /><h2>Change password</h2><form method="POST" action="?/changePassword"><label class="field">CURRENT PASSWORD<input name="currentPassword" type="password" required /></label><label class="field">NEW PASSWORD<input name="newPassword" type="password" minlength="12" required /></label><label class="field">CONFIRM PASSWORD<input name="confirmPassword" type="password" minlength="12" required /></label>{#if form?.passwordError}<p class="error">{form.passwordError}</p>{/if}<div class="form-actions"><button class="primary">UPDATE PASSWORD</button></div></form></aside>
|
||||||
|
</div></main>
|
||||||
@@ -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 {};
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
let { children } = $props();
|
||||||
|
const sections = [
|
||||||
|
{ href: '/admin/stats', label: 'Stats & attributes', code: 'ATTR' },
|
||||||
|
{ href: '/admin/talents', label: 'Talents & triggers', code: 'TLNT' },
|
||||||
|
{ href: '/admin/equipment', label: 'Equipment types', code: 'EQPT', pending: true },
|
||||||
|
{ href: '/admin/weapons', label: 'Weapons', code: 'WPN', pending: true }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main class="admin-page">
|
||||||
|
<header class="admin-command">
|
||||||
|
<div class="command-title"><p class="eyebrow"><span></span> SHD DATA OPERATIONS</p><h1>ADMIN CONSOLE</h1><p>Embedded game-data control surface</p></div>
|
||||||
|
<div class="command-status"><div><i></i><span>DATABASE</span><strong>ONLINE</strong></div><div><span>ACCESS</span><strong>ADMIN</strong></div><div><span>ENVIRONMENT</span><strong>LOCAL</strong></div></div>
|
||||||
|
</header>
|
||||||
|
<div class="admin-dashboard">
|
||||||
|
<aside class="admin-sidebar">
|
||||||
|
<div class="sidebar-label"><span>DATA MANAGEMENT</span><b>04 MODULES</b></div>
|
||||||
|
<nav aria-label="Admin sections">
|
||||||
|
{#each sections as section}
|
||||||
|
<a class:active={page.url.pathname === section.href} class:pending={section.pending} href={section.href}>
|
||||||
|
<small>{section.code}</small><span>{section.label}</span>{#if section.pending}<em>SOON</em>{:else}<b>›</b>{/if}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-foot"><span>SHD NODE</span><strong>NYC-01</strong><small>Schema synchronized</small></div>
|
||||||
|
</aside>
|
||||||
|
<section class="admin-content">{@render children()}</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
export const load = () => redirect(303, '/admin/stats');
|
||||||
@@ -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<number|null>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
let { data, form } = $props();
|
||||||
|
let editing = $state<any>(null);
|
||||||
|
const blank = () => ({id:'',name:'',type:'Offensive',category:'Weapon',calculation_type:'Additive',unit:'%',gear_min:'',gear_max:'',weapon_min:'',weapon_max:'',skill_chip_min:'',skill_chip_max:'',cap_value:''});
|
||||||
|
const value = (input: unknown) => input ?? '';
|
||||||
|
</script>
|
||||||
|
<svelte:head><title>Stats & attributes — SHD.build admin</title></svelte:head>
|
||||||
|
<div class="module-toolbar"><div><p class="eyebrow">ATTRIBUTE REGISTRY</p><h2>STATS & ATTRIBUTES</h2><p>Builder values, hidden calculation targets, source ranges, and caps.</p></div><button class="primary" onclick={() => editing=blank()}>ADD ATTRIBUTE <span>+</span></button></div>
|
||||||
|
<div class="module-kpis"><div><span>TOTAL RECORDS</span><strong>{data.stats.length}</strong><small>All definitions</small></div><div><span>ACTIVE</span><strong>{data.stats.filter((item)=>Number(item.active)===1).length}</strong><small>Available to systems</small></div><div><span>OFFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Offensive').length}</strong><small>Damage-oriented</small></div><div><span>DEFENSIVE</span><strong>{data.stats.filter((item)=>item.type==='Defensive').length}</strong><small>Survival-oriented</small></div></div>
|
||||||
|
{#if editing}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{editing.id?'MODIFY RECORD':'NEW RECORD'}</p><h3>{editing.id?editing.name:'ATTRIBUTE DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing=null}>×</button></div><PendingForm action="?/save" class="grid"><input type="hidden" name="id" value={editing.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={editing.name} required /></label><label class="field">TYPE<select name="type" bind:value={editing.type}><option>Offensive</option><option>Defensive</option></select></label><label class="field">CATEGORY<select name="category" bind:value={editing.category}><option>Weapon</option><option>Skill</option><option>Character</option></select></label><label class="field">CALCULATION<select name="calculationType" bind:value={editing.calculation_type}><option>Additive</option><option>Multiplicative</option></select></label><label class="field">UNIT<input name="unit" bind:value={editing.unit}/></label>{#each [['gear','GEAR'],['weapon','WEAPON'],['skill_chip','SKILL CHIP']] as source}<div class="field range-field"><span class="label">{source[1]} RANGE · OPTIONAL</span><div><input name={`${source[0]==='skill_chip'?'chip':source[0]}Min`} type="number" step="any" value={value(editing[`${source[0]}_min`])} placeholder="MIN"/><input name={`${source[0]==='skill_chip'?'chip':source[0]}Max`} type="number" step="any" value={value(editing[`${source[0]}_max`])} placeholder="MAX"/></div></div>{/each}<label class="field">OPTIONAL CAP<input name="capValue" type="number" step="any" value={value(editing.cap_value)}/></label></div>{#if form?.error}<p class="error">{form.error}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={() => editing=null}>CANCEL</button><button class="primary">SAVE ATTRIBUTE →</button></div></PendingForm></div>{/if}
|
||||||
|
<div class="dashboard-table"><div class="dashboard-row table-heading"><span>ATTRIBUTE</span><span>CLASSIFICATION</span><span>CATEGORY</span><span>CALCULATION</span><span>STATE</span><span></span></div>{#each data.stats as stat}<div class="dashboard-row" class:archived={Number(stat.active)!==1}><div><strong>{stat.name}</strong><small>{stat.unit}</small></div><span class:type-offensive={stat.type==='Offensive'} class:type-defensive={stat.type==='Defensive'}>{stat.type}</span><span>{stat.category}</span><span>{stat.calculation_type}</span><b class="state-dot">{Number(stat.active)===1?'ACTIVE':'ARCHIVED'}</b><button onclick={() => editing={...stat}}>EDIT</button></div>{/each}</div>
|
||||||
@@ -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};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||||
|
let {data,form}=$props();
|
||||||
|
type Effect={statId:string;value:string};
|
||||||
|
let talent=$state<any>(null),trigger=$state<any>(null),uptime=$state(false);
|
||||||
|
const blankTalent=()=>({id:'',name:'',category:'Weapon',I:{description:'',triggerIds:[],effects:[{statId:'',value:''}]},II:{description:'',triggerIds:[],effects:[{statId:'',value:''}]}});
|
||||||
|
const editTalent=(row:any)=>{const tier=(name:string)=>row.tiers.find((item:any)=>item.tier===name)??{description:'',triggerIds:[],effects:[]};const one=tier('I'),two=tier('II');talent={...row,I:{description:one.description,triggerIds:[...one.triggerIds],effects:one.effects.map((e:any)=>({statId:String(e.stat_attribute_id),value:String(e.value)}))},II:{description:two.description,triggerIds:[...two.triggerIds],effects:two.effects.map((e:any)=>({statId:String(e.stat_attribute_id),value:String(e.value)}))}}};
|
||||||
|
const blankTrigger=()=>{uptime=false;trigger={id:'',name:'',description:'',min_uptime:'',max_uptime:''}};
|
||||||
|
const editTrigger=(row:any)=>{uptime=Number(row.supports_uptime)===1;trigger={...row}};
|
||||||
|
const toggle=(tier:'I'|'II',id:string,checked:boolean)=>talent[tier].triggerIds=checked?[...talent[tier].triggerIds,id]:talent[tier].triggerIds.filter((value:string)=>value!==id);
|
||||||
|
const addEffect=(tier:'I'|'II')=>talent[tier].effects=[...talent[tier].effects,{statId:'',value:''}];
|
||||||
|
const removeEffect=(tier:'I'|'II',index:number)=>talent[tier].effects=talent[tier].effects.filter((_:Effect,row:number)=>row!==index);
|
||||||
|
const configured=(tiers:Array<{tier:unknown}>,name:string)=>tiers.some((item)=>item.tier===name);
|
||||||
|
</script>
|
||||||
|
<svelte:head><title>Talents & triggers — SHD.build admin</title></svelte:head>
|
||||||
|
<div class="module-toolbar"><div><p class="eyebrow">TALENT REGISTRY</p><h2>TALENTS & TRIGGERS</h2><p>Tier behavior, reusable activation conditions, uptime, and attribute effects.</p></div><div><button class="secondary" onclick={blankTrigger}>ADD TRIGGER +</button><button class="primary" onclick={()=>talent=blankTalent()}>ADD TALENT +</button></div></div>
|
||||||
|
<div class="module-kpis"><div><span>TALENTS</span><strong>{data.talents.length}</strong><small>Registered definitions</small></div><div><span>TRIGGERS</span><strong>{data.triggers.length}</strong><small>Reusable conditions</small></div><div><span>WEAPON</span><strong>{data.talents.filter((item)=>item.category==='Weapon').length}</strong><small>Weapon talents</small></div><div><span>GEAR</span><strong>{data.talents.filter((item)=>item.category!=='Weapon').length}</strong><small>Vest and backpack</small></div></div>
|
||||||
|
{#if trigger}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{trigger.id?'MODIFY TRIGGER':'NEW TRIGGER'}</p><h3>{trigger.id?trigger.name:'TRIGGER DEFINITION'}</h3></div><button aria-label="Close trigger editor" onclick={()=>trigger=null}>×</button></div><PendingForm action="?/saveTrigger"><input type="hidden" name="id" value={trigger.id}/><div class="form-grid"><label class="field">NAME<input name="name" bind:value={trigger.name} required/></label><label class="field">DESCRIPTION<textarea name="description" bind:value={trigger.description} required></textarea></label><label class="field uptime-field"><span><input name="supportsUptime" type="checkbox" bind:checked={uptime}/> ENABLE THEORETICAL UPTIME</span><div><input name="minUptime" type="number" min="0" max="100" bind:value={trigger.min_uptime} disabled={!uptime} placeholder="MIN %"/><input name="maxUptime" type="number" min="0" max="100" bind:value={trigger.max_uptime} disabled={!uptime} placeholder="MAX %"/></div></label><p class="script-note"><b>CALCULATION SCRIPT</b><span>Reserved for the future calculation engine</span></p></div>{#if form?.triggerError}<p class="error">{form.triggerError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>trigger=null}>CANCEL</button><button class="primary">SAVE TRIGGER →</button></div></PendingForm></div>{/if}
|
||||||
|
{#if talent}<div class="dashboard-editor"><div class="editor-title"><div><p class="eyebrow">{talent.id?'MODIFY TALENT':'NEW TALENT'}</p><h3>{talent.id?talent.name:'TALENT DEFINITION'}</h3></div><button aria-label="Close talent editor" onclick={()=>talent=null}>×</button></div><PendingForm action="?/saveTalent"><input type="hidden" name="id" value={talent.id}/><input type="hidden" name="tierIEffects" value={JSON.stringify(talent.I.effects)}/><input type="hidden" name="tierIIEffects" value={JSON.stringify(talent.II.effects)}/><div class="form-grid talent-identity"><label class="field">NAME<input name="name" bind:value={talent.name} required/></label><label class="field">CATEGORY<select name="category" bind:value={talent.category}><option>Weapon</option><option>Backpack</option><option>Vest</option></select></label></div><div class="tier-grid">{#each ['I','II'] as tier}<fieldset class="tier-card"><legend>TIER {tier}</legend><label class="field">DISPLAY DESCRIPTION<textarea name={tier==='I'?'tierIDescription':'tierIIDescription'} bind:value={talent[tier].description} required></textarea></label><p class="label">ACTIVATION TRIGGERS</p><div class="trigger-list">{#each data.triggers.filter((item)=>Number(item.active)===1) as item}<div><label><input name={tier==='I'?'tierITriggerIds':'tierIITriggerIds'} type="checkbox" value={item.id} checked={talent[tier].triggerIds.includes(String(item.id))} onchange={(event)=>toggle(tier as 'I'|'II',String(item.id),event.currentTarget.checked)}/> <span>{item.name}</span></label><button type="button" onclick={()=>editTrigger(item)}>EDIT</button></div>{/each}</div><div class="effects-head"><p class="label">ATTRIBUTE EFFECTS</p><button type="button" onclick={()=>addEffect(tier as 'I'|'II')}>ADD EFFECT +</button></div>{#each talent[tier].effects as effect,index}<div class="talent-effect"><select bind:value={effect.statId} required><option value="">Select attribute…</option>{#each data.stats as stat}<option value={stat.id}>{stat.name}</option>{/each}</select><input type="number" step="any" bind:value={effect.value} placeholder="VALUE" required/><button type="button" onclick={()=>removeEffect(tier as 'I'|'II',index)}>×</button></div>{/each}</fieldset>{/each}</div>{#if form?.talentError}<p class="error">{form.talentError}</p>{/if}<div class="form-actions"><button type="button" class="secondary" onclick={()=>talent=null}>CANCEL</button><button class="primary">SAVE TALENT →</button></div></PendingForm></div>{/if}
|
||||||
|
<div class="dashboard-table talent-dashboard-table"><div class="dashboard-row table-heading"><span>TALENT</span><span>CATEGORY</span><span>TIER I</span><span>TIER II</span><span>STATE</span><span></span></div>{#each data.talents as item}<div class="dashboard-row"><div><strong>{item.name}</strong><small>Tiered talent</small></div><span>{item.category}</span><span class:configured={configured(item.tiers,'I')}>{configured(item.tiers,'I')?'CONFIGURED':'MISSING'}</span><span class:configured={configured(item.tiers,'II')}>{configured(item.tiers,'II')?'CONFIGURED':'MISSING'}</span><b class="state-dot">ACTIVE</b><button onclick={()=>editTalent(item)}>EDIT</button></div>{/each}</div>
|
||||||
@@ -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'
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
};
|
||||||
@@ -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<string> {
|
||||||
|
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');
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
type Slot = { name: string; item: string; brand: string; core: string };
|
||||||
|
let buildName = $state('Untitled loadout');
|
||||||
|
let saved = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
let slots = $state<Slot[]>([
|
||||||
|
{ name: 'Primary weapon', item: 'Police M4', brand: 'Assault rifle', core: '5,184 DMG' },
|
||||||
|
{ name: 'Secondary weapon', item: 'SVD', brand: 'Marksman rifle', core: '8,420 DMG' },
|
||||||
|
{ name: 'Mask', item: 'Striker Mask', brand: "Striker's Battlegear", core: '+42 Firearms' },
|
||||||
|
{ name: 'Vest', item: 'Reinforced Vest', brand: 'Fenris Group AB', core: '+38 Armor' },
|
||||||
|
{ name: 'Backpack', item: 'Field Pack', brand: "Striker's Battlegear", core: '+39 Firearms' },
|
||||||
|
{ name: 'Gloves', item: 'Tactical Gloves', brand: 'Sokolov Concern', core: '+6.5% CHC' }
|
||||||
|
]);
|
||||||
|
onMount(() => {
|
||||||
|
const draft = localStorage.getItem('shd-build-loadout-draft');
|
||||||
|
if (!draft) return;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(draft);
|
||||||
|
buildName = parsed.buildName ?? buildName;
|
||||||
|
slots = parsed.slots ?? slots;
|
||||||
|
saved = true;
|
||||||
|
} catch { localStorage.removeItem('shd-build-loadout-draft'); }
|
||||||
|
});
|
||||||
|
function saveDraft() {
|
||||||
|
localStorage.setItem('shd-build-loadout-draft', JSON.stringify({ buildName, slots, updatedAt: new Date().toISOString() }));
|
||||||
|
saved = true; message = 'Draft saved on this device.';
|
||||||
|
setTimeout(() => (message = ''), 2200);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Build a loadout — SHD.build</title></svelte:head>
|
||||||
|
<main class="app-page">
|
||||||
|
<div class="page-title"><div><p class="eyebrow"><span></span> LOADOUT WORKBENCH</p><input bind:value={buildName} aria-label="Build name" /></div><div class="actions"><span class:visible={saved}>SAVED LOCALLY</span><button class="primary" onclick={saveDraft}>SAVE DRAFT</button></div></div>
|
||||||
|
{#if message}<p class="success" role="status">{message}</p>{/if}
|
||||||
|
<div class="builder-layout">
|
||||||
|
<section class="slot-grid">{#each slots as slot}<button class="slot-card" onclick={() => (message = `${slot.name} editing will be added in the next builder increment.`)}><span class="slot-label">{slot.name}</span><span class="slot-icon">◇</span><strong>{slot.item}</strong><small>{slot.brand}</small><b>{slot.core}</b></button>{/each}</section>
|
||||||
|
<aside class="summary"><p class="eyebrow"><span></span> LIVE SUMMARY</p><h2>AGENT STATS</h2><div class="stat"><span>Firepower</span><strong>4,218</strong><i style="width:76%"></i></div><div class="stat"><span>Armor</span><strong>2,840</strong><i style="width:53%"></i></div><div class="stat"><span>Skill power</span><strong>1,265</strong><i style="width:30%"></i></div><hr/><div class="mini"><span>Critical chance</span><b>52.5%</b></div><div class="mini"><span>Critical damage</span><b>118%</b></div><p class="notice">Anonymous drafts stay on this device. Sign in to publish and sync builds.</p></aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const builds = [
|
||||||
|
{ title: 'Striker Critical Rush', author: 'AgentNova', mode: 'PVE', rating: 4.9 },
|
||||||
|
{ title: 'Ironclad Vanguard', author: 'KelsoMain', mode: 'PVP', rating: 4.8 },
|
||||||
|
{ title: 'Status Protocol', author: 'SHD_Warden', mode: 'PVE', rating: 4.7 }
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
<svelte:head><title>Discover builds — SHD.build</title></svelte:head>
|
||||||
|
<main class="page"><div class="page-head"><div><p class="eyebrow">FIELD-TESTED LOADOUTS</p><h1>Discover builds</h1><p class="muted">Browse, sort, rate, and copy community builds.</p></div><a class="primary" href="/builder">NEW BUILD +</a></div><section class="grid cards">{#each builds as build}<article class="card"><small>{build.mode} · ★ {build.rating}</small><h3>{build.title}</h3><p class="muted">BY {build.author.toUpperCase()}</p><button class="secondary">VIEW BUILD →</button></article>{/each}</section></main>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<svelte:head><title>Build guides — SHD.build</title></svelte:head>
|
||||||
|
<main class="page"><div class="page-head"><div><p class="eyebrow">FIELD NOTES</p><h1>Build guides</h1><p class="muted">Detailed community strategies and loadout walkthroughs will live here.</p></div></div><div class="panel"><h2>Guides are coming next</h2><p class="muted">The route is ready for guide listing, search, authorship, and rich build-linked articles.</p></div></main>
|
||||||
@@ -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, '/');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<script lang="ts">let { data, form } = $props();</script>
|
||||||
|
<svelte:head><title>Sign in — SHD.build</title></svelte:head>
|
||||||
|
<main class="auth-page"><section><p class="eyebrow"><span></span> AGENT ACCESS</p><h1>WELCOME BACK</h1><p>Sign in to publish builds, join discussions, and keep your loadouts synced.</p><a class="discord" href="/auth/discord">◉ CONTINUE WITH DISCORD</a><div class="divider"><span>OR USE YOUR ACCOUNT</span></div><form method="POST" action="?/login"><label>USERNAME<input name="username" value={form?.username ?? ''} autocomplete="username" required /></label><label>PASSWORD<input name="password" type="password" autocomplete="current-password" required /></label>{#if form?.loginError}<p class="error">{form.loginError}</p>{/if}{#if data.oauthError}<p class="error">{data.oauthError}</p>{/if}<button class="primary full">SIGN IN →</button></form></section></main>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import type { PageLoad } from './$types';
|
||||||
|
export const load: PageLoad = ({ url }) => ({ oauthError: url.searchParams.has('oauth_error') });
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#0a0c0d"/>
|
||||||
|
<circle cx="32" cy="32" r="24" fill="none" stroke="#f6a821" stroke-width="2"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#f6a821" stroke-opacity=".38" stroke-width="1"/>
|
||||||
|
<path d="M32 4v7M32 53v7M4 32h7M53 32h7" stroke="#f6a821" stroke-width="2"/>
|
||||||
|
<circle cx="46" cy="18" r="3" fill="#f6a821"/>
|
||||||
|
<circle cx="18" cy="46" r="3" fill="#f6a821"/>
|
||||||
|
<text x="32" y="36" fill="#f6a821" font-family="Arial,Helvetica,sans-serif" font-size="11" font-weight="900" text-anchor="middle" letter-spacing=".5">SHD</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 640 B |
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user