feat(website): add the project site and deploy it to GitHub Pages
A Vite + React + TypeScript + Tailwind v4 site under website/, built from the soulseek-rs design system so the site and the TUI stay one product. The colour tokens in src/index.css are the same values as soulseek-rs/src/ui/styles.rs; change one, change both. The hero is a working recreation of the TUI: it plays one scripted run and then takes the keyboard, or lands on the resting state directly under prefers-reduced-motion. Its sample data is invented or public domain, per the tone-of-voice guardrail against naming copyrighted material. Static output rather than an SPA shell. `bun run build` compiles the client, compiles the same app for Node, then renders every route in src/lib/routes.ts to its own file with that route's title, description and canonical URL. A direct link to /install therefore answers 200 with real markup instead of GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding one without the other fails typecheck rather than shipping a page whose body says "not found" under the right title. Theme is resolved once by an inline script before first paint and adopted by React through useSyncExternalStore, so there is no flash and no hydration mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor and tape read on vinyl but wash out on manila. CI and release skip website-only pushes, and Pages skips Rust-only ones. The two Rust workflows share one paths-ignore list so they can only skip together, which keeps the release ci-gate from polling for a run that was never queued. That gate also now treats a cancelled CI as "keep waiting" rather than a failure, since cancel-in-progress cancels the older SHA on every back-to-back push.
This commit is contained in:
@@ -2,9 +2,16 @@ name: CI
|
||||
|
||||
# Branch pushes are covered by the pull_request trigger. Listening to both for
|
||||
# every branch ran the whole matrix twice per PR.
|
||||
#
|
||||
# website/ holds the GitHub Pages site and no Rust. Ignoring it here must stay
|
||||
# in lockstep with the same list in release.yml: the two skip together, so the
|
||||
# release ci-gate never waits for a CI run that was never queued. Deliberately
|
||||
# not applied to pull_request — CI is a required check there, and a skipped
|
||||
# required check leaves the PR permanently unmergeable.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths-ignore: ['website/**']
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Pages
|
||||
|
||||
# Only the website. The Rust workflows ignore this same path, so a site-only
|
||||
# push runs this alone and a code-only push never rebuilds the site.
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths: ['website/**', '.github/workflows/pages.yml']
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
paths: ['website/**', '.github/workflows/pages.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: website
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# --frozen-lockfile so a drifted bun.lock fails here rather than
|
||||
# silently shipping different dependency versions than were tested.
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: bun run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
|
||||
# Emits one static HTML file per route plus a 404, so direct links
|
||||
# answer 200 with real markup instead of the SPA shell.
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: website/dist
|
||||
|
||||
deploy:
|
||||
name: Deploy
|
||||
# Pull requests get the build as a check; only master publishes.
|
||||
if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Scoped to the deploy, not the workflow: a workflow-level group would
|
||||
# queue PR builds behind an in-flight master deploy for no reason. Never
|
||||
# cancel in flight — a half-uploaded artifact leaves the live site broken.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -2,7 +2,10 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
# Must match ci.yml's paths-ignore exactly. A website-only push skips both
|
||||
# workflows, so ci-gate is never left polling for an absent CI run.
|
||||
branches: [master]
|
||||
paths-ignore: ['website/**']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -48,7 +51,11 @@ jobs:
|
||||
2>/dev/null || echo "unreachable")
|
||||
case "$verdict" in
|
||||
success) echo "CI passed for $GITHUB_SHA"; exit 0 ;;
|
||||
absent|running|unreachable) ;;
|
||||
# `cancelled` is what CI's own cancel-in-progress does to the
|
||||
# older SHA when two pushes land back to back. Treating it as a
|
||||
# failure turned every rapid push into a red release; keep
|
||||
# waiting instead, and let the newer SHA's run decide.
|
||||
absent|running|unreachable|cancelled) ;;
|
||||
*) echo "::error::CI concluded '$verdict' for $GITHUB_SHA - refusing to release"; exit 1 ;;
|
||||
esac
|
||||
sleep 20
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,50 @@
|
||||
# website
|
||||
|
||||
The soulseek-rs project site. Vite + React + TypeScript + Tailwind v4,
|
||||
deployed to GitHub Pages by `.github/workflows/pages.yml`.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev # http://localhost:5173/soulseek-rs/
|
||||
bun run build # client build, SSR build, then prerender
|
||||
bun run typecheck
|
||||
bun run lint
|
||||
```
|
||||
|
||||
## How the static site is produced
|
||||
|
||||
`bun run build` runs three steps:
|
||||
|
||||
1. `vite build` — the client bundle, plus `dist/index.html` as the template.
|
||||
2. `vite build --ssr src/entry-server.tsx` — the same app compiled for Node.
|
||||
3. `bun scripts/prerender.ts` — renders every route in `src/lib/routes.ts` to
|
||||
its own file (`dist/install/index.html`, and so on) with that route's title
|
||||
and description substituted in, plus a `404.html`.
|
||||
|
||||
Without step 3 GitHub Pages has no file at `/install` and answers a direct link
|
||||
with a 404. With it, every URL returns 200 with fully rendered markup, and the
|
||||
client hydrates that markup rather than replacing it.
|
||||
|
||||
Adding a page means: a component in `src/pages/`, a `<Route>` in `src/App.tsx`,
|
||||
and an entry in `src/lib/routes.ts`. The last one is what the prerenderer walks.
|
||||
|
||||
## Design system
|
||||
|
||||
Tokens live in `src/index.css`, ported from the Claude Design project's
|
||||
`tokens/colors.css`. They are the same values that back the TUI in
|
||||
`soulseek-rs/src/ui/styles.rs` — change one, change both.
|
||||
|
||||
Dark is the native theme; light re-cuts the semantic hues roughly 10% darker,
|
||||
because phosphor and tape are legible on vinyl but wash out on manila. Theme
|
||||
selection is `[data-theme]` on `<html>`, defaulting to the OS setting, with an
|
||||
inline script in `index.html` applying it before first paint so the page never
|
||||
flashes the wrong background.
|
||||
|
||||
## Notes
|
||||
|
||||
- `base` in `vite.config.ts` is `/soulseek-rs/` because this is a project site.
|
||||
Pointing a custom domain at it means setting that to `/` (the router reads
|
||||
`import.meta.env.BASE_URL`, so it follows automatically).
|
||||
- Copy in `src/pages/` follows the project's tone of voice: plainspoken,
|
||||
precise, honest about limits first. No copyrighted artist or album names
|
||||
anywhere, including the TUI demo data in `src/components/tui/data.ts`.
|
||||
@@ -0,0 +1,713 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "soulseek-rs-website",
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.1.5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
|
||||
|
||||
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||
|
||||
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="],
|
||||
|
||||
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="],
|
||||
|
||||
"acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
||||
"array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="],
|
||||
|
||||
"array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
|
||||
|
||||
"array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
|
||||
|
||||
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
|
||||
|
||||
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
|
||||
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
|
||||
|
||||
"axe-core": ["axe-core@4.12.1", "", {}, "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.1", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
|
||||
|
||||
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
|
||||
|
||||
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
|
||||
|
||||
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
|
||||
|
||||
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
|
||||
|
||||
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.396", "", {}, "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="],
|
||||
|
||||
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
|
||||
|
||||
"es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
|
||||
|
||||
"es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@10.7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||
|
||||
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||
|
||||
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.4.3", "", {}, "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ=="],
|
||||
|
||||
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="],
|
||||
|
||||
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
||||
|
||||
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="],
|
||||
|
||||
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
||||
|
||||
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
|
||||
|
||||
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
|
||||
|
||||
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
|
||||
|
||||
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
|
||||
|
||||
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
|
||||
|
||||
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
|
||||
|
||||
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
|
||||
|
||||
"is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
|
||||
|
||||
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
|
||||
|
||||
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
|
||||
|
||||
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
|
||||
|
||||
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
|
||||
|
||||
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
|
||||
|
||||
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
|
||||
|
||||
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
|
||||
|
||||
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
|
||||
|
||||
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
|
||||
|
||||
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
|
||||
|
||||
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
|
||||
|
||||
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
|
||||
|
||||
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
|
||||
|
||||
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
|
||||
|
||||
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
|
||||
|
||||
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
|
||||
|
||||
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
|
||||
|
||||
"object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
|
||||
|
||||
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
|
||||
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||
|
||||
"react-router": ["react-router@8.3.0", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ=="],
|
||||
|
||||
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
|
||||
|
||||
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="],
|
||||
|
||||
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
|
||||
|
||||
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
|
||||
|
||||
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
|
||||
|
||||
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
|
||||
|
||||
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
|
||||
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
|
||||
|
||||
"string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="],
|
||||
|
||||
"string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="],
|
||||
|
||||
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
|
||||
|
||||
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
|
||||
|
||||
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="],
|
||||
|
||||
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="],
|
||||
|
||||
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
||||
|
||||
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
|
||||
|
||||
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.16", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw=="],
|
||||
|
||||
"eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import js from '@eslint/js'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default defineConfig(
|
||||
{ ignores: ['dist', 'dist-ssr', 'node_modules'] },
|
||||
|
||||
js.configs.recommended,
|
||||
|
||||
// strictTypeChecked over recommended: it needs type information (below) but
|
||||
// catches the whole class of bugs that only types can see — floating
|
||||
// promises, unsafe `any` flowing through, misused nullish operands.
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// `configs.flat.*`, not `configs['recommended-latest']` — the latter is
|
||||
// still the legacy eslintrc shape (plugins as a string array).
|
||||
reactHooks.configs.flat['recommended-latest'],
|
||||
jsxA11y.flatConfigs.recommended,
|
||||
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'error',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
|
||||
// Unused args are usually a mistake, but an `_`-prefixed one is an
|
||||
// explicit "I know, I need the positional slot".
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// Type-only imports must be erasable, so the bundler can drop them
|
||||
// without consulting the type graph. Pairs with verbatimModuleSyntax.
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
|
||||
// A dropped promise in an event handler fails silently in production.
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
'object-shorthand': 'error',
|
||||
},
|
||||
},
|
||||
|
||||
// The TUI demo is a composite widget: it declares role="application" and
|
||||
// owns its keys (j/k, arrows, 1-3) the way the real terminal client does.
|
||||
// These two rules police exactly the pattern ARIA prescribes for that, so
|
||||
// they are off for this one file rather than worked around in the markup.
|
||||
// Every control inside it is still a native button or input.
|
||||
{
|
||||
files: ['src/components/tui/hero-terminal.tsx'],
|
||||
rules: {
|
||||
'jsx-a11y/no-noninteractive-tabindex': 'off',
|
||||
'jsx-a11y/no-noninteractive-element-interactions': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// Config and build scripts are Node, not browser. Build output is meant to
|
||||
// be read on the terminal, so console is the interface, not a stray debug
|
||||
// statement. entry-server is a build entry point that never enters the HMR
|
||||
// graph, so the component-only export rule does not apply to it.
|
||||
{
|
||||
files: ['*.config.ts', 'scripts/**/*.ts'],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/entry-server.tsx'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- Root-relative on purpose: Vite rewrites this against `base`, so the
|
||||
deploy path lives in vite.config.ts alone. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<title>soulseek-rs, a Soulseek client for your terminal</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="soulseek-rs is a keyboard-driven Soulseek client and a Rust protocol library. Search, share, browse, and chat from your terminal."
|
||||
/>
|
||||
<!-- Browser chrome follows the theme; one value would be wrong half the time. -->
|
||||
<meta name="theme-color" content="#14110F" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#F2EDE4" media="(prefers-color-scheme: light)" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content="soulseek-rs" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="A Soulseek client and protocol library for Rust. Search, share, browse, and chat from your terminal."
|
||||
/>
|
||||
<!-- og:url and canonical are rewritten per route by scripts/prerender.ts. -->
|
||||
<meta property="og:url" content="https://michel.github.io/soulseek-rs/" />
|
||||
<link rel="canonical" href="https://michel.github.io/soulseek-rs/" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
|
||||
<!-- IBM Plex Mono is the brand typeface. CDN rather than self-hosted
|
||||
binaries, matching the design system's tokens/fonts.css. OFL 1.1. -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;700&display=swap"
|
||||
/>
|
||||
|
||||
<script>
|
||||
// Runs before first paint so the right theme is already on <html>.
|
||||
// Without it the page flashes the wrong background on every load.
|
||||
// Mirrors the resolution order in src/theme/theme-provider.tsx.
|
||||
;(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem('ssrs-theme')
|
||||
var dark =
|
||||
stored === 'dark' ||
|
||||
((stored === null || stored === 'system') &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
var theme = dark ? 'dark' : 'light'
|
||||
document.documentElement.dataset.theme = theme
|
||||
document.documentElement.style.colorScheme = theme
|
||||
} catch (e) {
|
||||
// localStorage blocked; the dark default in the markup stands.
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "soulseek-rs-website",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build && bun run build:ssr && bun run prerender",
|
||||
"build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr",
|
||||
"prerender": "bun scripts/prerender.ts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router": "^8.3.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"vite": "^8.1.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="160" height="160" viewBox="0 0 160 160" fill="none">
|
||||
<g id="Favicon">
|
||||
<rect width="160" height="160" rx="16" fill="#E8E1D6"></rect>
|
||||
<path id="Letter S" d="M53.496 116.768C49.7413 116.768 46.4773 116.149 43.704 114.912C40.9307 113.675 38.6907 111.989 36.984 109.856L42.552 103.712C44.1733 105.504 45.944 106.827 47.864 107.68C49.8267 108.533 51.8107 108.96 53.816 108.96C56.12 108.96 57.8693 108.448 59.064 107.424C60.2587 106.4 60.856 104.928 60.856 103.008C60.856 101.429 60.3867 100.235 59.448 99.424C58.552 98.6133 57.016 98.0373 54.84 97.696L50.168 96.928C46.1573 96.2453 43.256 94.7733 41.464 92.512C39.672 90.208 38.776 87.4133 38.776 84.128C38.776 79.8187 40.184 76.4907 43 74.144C45.816 71.7547 49.7627 70.56 54.84 70.56C58.296 70.56 61.304 71.0933 63.864 72.16C66.424 73.2267 68.4933 74.6773 70.072 76.512L64.632 82.592C63.4373 81.2693 62.0293 80.2453 60.408 79.52C58.7867 78.752 56.952 78.368 54.904 78.368C50.5947 78.368 48.44 80.1387 48.44 83.68C48.44 85.216 48.9093 86.368 49.848 87.136C50.8293 87.904 52.408 88.48 54.584 88.864L59.256 89.696C62.8827 90.336 65.6773 91.7013 67.64 93.792C69.6027 95.8827 70.584 98.6987 70.584 102.24C70.584 104.331 70.2213 106.272 69.496 108.064C68.7707 109.813 67.6827 111.349 66.232 112.672C64.7813 113.952 62.9893 114.955 60.856 115.68C58.7653 116.405 56.312 116.768 53.496 116.768Z" fill="#14110F"></path>
|
||||
<rect id="Cursor" x="91" y="48" width="31" height="72" rx="3" fill="#C1502E"></rect>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 59 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Turns the SPA build into real static files.
|
||||
*
|
||||
* Without this, GitHub Pages has no file at /install and answers a direct
|
||||
* link with its 404 page. Writing one HTML file per route means every URL
|
||||
* returns 200 with its own title, description, and fully rendered body; the
|
||||
* client then hydrates it into the same interactive app.
|
||||
*
|
||||
* Run after both `vite build` and `vite build --ssr`.
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { NOT_FOUND_META, ROUTES, type RouteMeta } from '../src/lib/routes.ts'
|
||||
|
||||
/*
|
||||
* Held in a variable so TypeScript does not try to resolve it: the module is
|
||||
* a build artefact, and `bun run typecheck` runs before `vite build --ssr`
|
||||
* has produced it. The cast is the contract with src/entry-server.tsx.
|
||||
*/
|
||||
const SSR_ENTRY = '../dist-ssr/entry-server.js'
|
||||
const { render } = (await import(SSR_ENTRY)) as {
|
||||
render: (location: string) => string
|
||||
}
|
||||
|
||||
const DIST = join(import.meta.dirname, '..', 'dist')
|
||||
|
||||
const SITE_URL = 'https://michel.github.io/soulseek-rs/'
|
||||
|
||||
const escapeAttr = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
|
||||
/** Replaces the content of one `<meta>` matched by attribute and value. */
|
||||
const setMeta = (html: string, attr: string, name: string, value: string): string =>
|
||||
html.replace(
|
||||
new RegExp(`(<meta\\s+${attr}="${name}"\\s+content=")[\\s\\S]*?(")`),
|
||||
`$1${escapeAttr(value)}$2`,
|
||||
)
|
||||
|
||||
/**
|
||||
* Swaps every per-page tag in the template for the route's own.
|
||||
*
|
||||
* All five are listed here together on purpose: og:title and og:url used to be
|
||||
* left alone, so each prerendered page advertised the site title and the
|
||||
* homepage URL to anything reading Open Graph.
|
||||
*/
|
||||
const applyMeta = (html: string, meta: RouteMeta): string => {
|
||||
const canonical = meta.path === '/' ? SITE_URL : `${SITE_URL}${meta.path.slice(1)}/`
|
||||
let out = html.replace(
|
||||
/<title>[\s\S]*?<\/title>/,
|
||||
`<title>${escapeAttr(meta.title)}</title>`,
|
||||
)
|
||||
out = setMeta(out, 'name', 'description', meta.description)
|
||||
out = setMeta(out, 'property', 'og:title', meta.title)
|
||||
out = setMeta(out, 'property', 'og:description', meta.description)
|
||||
out = setMeta(out, 'property', 'og:url', canonical)
|
||||
return out.replace(
|
||||
/(<link\s+rel="canonical"\s+href=")[\s\S]*?(")/,
|
||||
`$1${escapeAttr(canonical)}$2`,
|
||||
)
|
||||
}
|
||||
|
||||
const writePage = async (
|
||||
template: string,
|
||||
meta: RouteMeta,
|
||||
outPath: string,
|
||||
location: string,
|
||||
): Promise<void> => {
|
||||
const html = applyMeta(template, meta).replace(
|
||||
'<div id="root"></div>',
|
||||
`<div id="root">${render(location)}</div>`,
|
||||
)
|
||||
const target = join(DIST, outPath)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
await writeFile(target, html, 'utf8')
|
||||
console.log(` ${outPath}`)
|
||||
}
|
||||
|
||||
const template = await readFile(join(DIST, 'index.html'), 'utf8')
|
||||
|
||||
console.log('prerendering:')
|
||||
for (const route of ROUTES) {
|
||||
const outPath = route.path === '/' ? 'index.html' : `${route.path.slice(1)}/index.html`
|
||||
await writePage(template, route, outPath, route.path)
|
||||
}
|
||||
|
||||
// GitHub Pages serves this whenever no file matches, which after the loop
|
||||
// above is only ever a genuinely unknown path.
|
||||
await writePage(template, NOT_FOUND_META, '404.html', '/__not_found__')
|
||||
|
||||
console.log(`done — ${String(ROUTES.length + 1)} pages`)
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import { Route, Routes } from 'react-router'
|
||||
|
||||
import { SiteLayout } from '@/components/layout/site-layout'
|
||||
import { ROUTES, type RoutePath } from '@/lib/routes'
|
||||
import { Community } from '@/pages/community'
|
||||
import { Docs } from '@/pages/docs'
|
||||
import { Home } from '@/pages/home'
|
||||
import { Install } from '@/pages/install'
|
||||
import { NotFound } from '@/pages/not-found'
|
||||
|
||||
/*
|
||||
* Keyed by RoutePath, so adding an entry to ROUTES without a page here — or a
|
||||
* page here that ROUTES does not know about — fails `bun run typecheck`.
|
||||
*
|
||||
* That drift used to be silent and shipped broken both ways: a prerendered
|
||||
* file containing NotFound markup under the new page's title, or a real page
|
||||
* with no prerendered file and "not found" in its tab.
|
||||
*/
|
||||
const PAGES: Record<RoutePath, ReactElement> = {
|
||||
'/': <Home />,
|
||||
'/install': <Install />,
|
||||
'/docs': <Docs />,
|
||||
'/community': <Community />,
|
||||
}
|
||||
|
||||
/*
|
||||
* Everything is eagerly imported: the whole site is a couple of hundred kB,
|
||||
* so route-splitting would trade a network round-trip on every navigation for
|
||||
* a bundle saving nobody would notice.
|
||||
*/
|
||||
export const App = () => (
|
||||
<Routes>
|
||||
<Route element={<SiteLayout />}>
|
||||
{ROUTES.map((route) =>
|
||||
route.path === '/' ? (
|
||||
<Route key={route.path} index element={PAGES[route.path]} />
|
||||
) : (
|
||||
<Route key={route.path} path={route.path} element={PAGES[route.path]} />
|
||||
),
|
||||
)}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface LogoProps {
|
||||
/** Height in px. Width follows the artwork's aspect ratio. */
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Native aspect ratio of the logo artwork (186.407 × 211). */
|
||||
const ASPECT = 186.407 / 211
|
||||
|
||||
/*
|
||||
* Two assets rather than one recoloured file — the ink and light cuts differ
|
||||
* in more than colour — swapped by CSS.
|
||||
*
|
||||
* Background images, not <img>: a hidden <img> is still downloaded, so the
|
||||
* two-element version fetched both cuts on every visit (~12-23 kB gzip wasted
|
||||
* above the fold). A background on a non-painted rule is never requested.
|
||||
*/
|
||||
export const Logo = ({ size = 180, className }: LogoProps) => (
|
||||
<div
|
||||
role="img"
|
||||
aria-label="soulseek-rs"
|
||||
className={cn('logo-mark shrink-0 bg-contain bg-no-repeat', className)}
|
||||
style={{ height: size, width: size * ASPECT }}
|
||||
/>
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WordmarkProps {
|
||||
/** Font size in px. */
|
||||
size?: number
|
||||
blink?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary soulseek-rs mark: lowercase mono wordmark plus a solid block
|
||||
* cursor. Live text rather than an asset, so it blinks for free and stays
|
||||
* crisp at any size.
|
||||
*/
|
||||
export const Wordmark = ({ size = 28, blink = false, className }: WordmarkProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center whitespace-nowrap font-mono font-medium leading-none tracking-[-0.01em] text-primary',
|
||||
className,
|
||||
)}
|
||||
style={{ fontSize: size }}
|
||||
>
|
||||
soulseek-rs
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'ml-[0.06em] inline-block h-[1.02em] w-[0.58em] translate-y-[0.04em] rounded-[1.5px] bg-accent',
|
||||
blink && 'motion-safe:animate-cursor-blink',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Wordmark } from '@/components/brand/wordmark'
|
||||
import { Wrap } from '@/components/ui/layout'
|
||||
import { LINKS, VERSION } from '@/lib/links'
|
||||
|
||||
interface FooterLink {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
interface FooterColumn {
|
||||
heading: string
|
||||
links: readonly FooterLink[]
|
||||
}
|
||||
|
||||
const COLUMNS: readonly FooterColumn[] = [
|
||||
{
|
||||
heading: 'Project',
|
||||
links: [
|
||||
{ label: 'GitHub', href: LINKS.gh },
|
||||
{ label: 'Issues', href: LINKS.issues },
|
||||
{ label: 'Releases', href: LINKS.releases },
|
||||
{ label: 'Changelog', href: LINKS.changelog },
|
||||
{ label: 'soulfind server', href: LINKS.soulfind },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Packages',
|
||||
links: [
|
||||
{ label: 'crates.io · client', href: LINKS.cratesClient },
|
||||
{ label: 'crates.io · lib', href: LINKS.cratesLib },
|
||||
{ label: 'docs.rs', href: LINKS.docsrs },
|
||||
{ label: 'rustup', href: LINKS.rustup },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: 'Read',
|
||||
links: [
|
||||
{ label: 'Docs', href: '/docs' },
|
||||
{ label: 'Install', href: '/install' },
|
||||
{ label: 'Community', href: '/community' },
|
||||
{ label: 'License · MIT', href: LINKS.license },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const LINK_CLASS = 'text-[13px] text-secondary hover:text-primary'
|
||||
|
||||
const FooterAnchor = ({ label, href }: FooterLink) =>
|
||||
href.startsWith('/') ? (
|
||||
<Link to={href} className={LINK_CLASS}>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
|
||||
export const Footer = () => (
|
||||
<footer className="border-t border-hairline bg-base pt-11 pb-10">
|
||||
<Wrap>
|
||||
<div className="grid grid-cols-1 gap-8 min-[520px]:grid-cols-2 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
|
||||
<div className="flex max-w-[320px] flex-col gap-3.5">
|
||||
<Wordmark size={22} />
|
||||
<p className="text-[13px] leading-[22px] text-secondary">
|
||||
A Soulseek client and protocol library for Rust.
|
||||
</p>
|
||||
<p className="font-forum text-xs text-muted">
|
||||
Not affiliated with or endorsed by the Soulseek project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{COLUMNS.map((column) => (
|
||||
<div key={column.heading}>
|
||||
<h2 className="mb-3.5 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||
{column.heading}
|
||||
</h2>
|
||||
<ul>
|
||||
{column.links.map((link) => (
|
||||
<li key={link.label} className="mb-2.5">
|
||||
<FooterAnchor {...link} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-11 flex flex-wrap items-center gap-x-6 gap-y-2 border-t border-hairline pt-5.5 text-[12.5px] text-muted">
|
||||
<span>MIT · © 2026 Michel de Graaf</span>
|
||||
<span className="text-success">No telemetry. Ever.</span>
|
||||
<span className="ml-auto">{VERSION}</span>
|
||||
</div>
|
||||
</Wrap>
|
||||
</footer>
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NavLink } from 'react-router'
|
||||
|
||||
import { Wordmark } from '@/components/brand/wordmark'
|
||||
import { ThemeToggle } from '@/components/layout/theme-toggle'
|
||||
import { LINKS } from '@/lib/links'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const ITEMS = [
|
||||
{ label: 'home', to: '/' },
|
||||
{ label: 'install', to: '/install' },
|
||||
{ label: 'docs', to: '/docs' },
|
||||
{ label: 'community', to: '/community' },
|
||||
] as const
|
||||
|
||||
/*
|
||||
* No hamburger. Below 820px the links become their own horizontally
|
||||
* scrollable row, which keeps every destination one tap away instead of
|
||||
* two — the site has four pages, a drawer would be ceremony.
|
||||
*/
|
||||
export const Nav = () => (
|
||||
<header className="sticky top-0 z-50 border-b border-hairline bg-[color-mix(in_srgb,var(--bg-base)_88%,transparent)] backdrop-blur-[8px]">
|
||||
<div className="mx-auto flex max-w-[1140px] flex-wrap items-center gap-x-4 gap-y-3 px-[18px] pt-2.5 sm:h-[60px] sm:flex-nowrap sm:gap-6 sm:px-7 sm:pt-0">
|
||||
<NavLink to="/" aria-label="soulseek-rs home" className="mr-auto flex items-center">
|
||||
<Wordmark size={19} blink />
|
||||
</NavLink>
|
||||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="order-3 flex w-full items-center gap-0.5 overflow-x-auto border-t border-hairline py-1 [-ms-overflow-style:none] [scrollbar-width:none] sm:order-none sm:w-auto sm:border-t-0 sm:py-0 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'relative inline-flex shrink-0 items-center rounded-md px-3.5 py-1.5 text-label uppercase tracking-[var(--tracking-label)] max-sm:min-h-11 sm:px-3',
|
||||
isActive
|
||||
? "bg-raised text-primary before:text-accent before:content-['›_'] after:absolute after:inset-x-3 after:bottom-px after:h-0.5 after:bg-accent"
|
||||
: 'text-secondary hover:bg-[var(--bg-selection)] hover:text-primary',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={LINKS.gh}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-[38px] items-center gap-1.5 rounded-md border border-hairline px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
|
||||
>
|
||||
GitHub <span aria-hidden="true">↗</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router'
|
||||
|
||||
import { Footer } from '@/components/layout/footer'
|
||||
import { Nav } from '@/components/layout/nav'
|
||||
import { usePageMeta } from '@/hooks/use-page-meta'
|
||||
|
||||
/**
|
||||
* On navigation: jump to the anchor if the URL carries one, otherwise back to
|
||||
* the top, and move keyboard focus into the main landmark.
|
||||
*
|
||||
* That last part matters — without it focus stays on the link that was
|
||||
* clicked, so a screen reader never announces that the page changed.
|
||||
*
|
||||
* replaces react-router's <ScrollRestoration>, which only works
|
||||
* under a data router. Restoring exact scroll offsets on back/forward would
|
||||
* mean adopting createBrowserRouter for four static pages.
|
||||
*/
|
||||
const useNavigationEffects = () => {
|
||||
const { pathname, hash } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (hash !== '') {
|
||||
document.querySelector(hash)?.scrollIntoView()
|
||||
} else {
|
||||
window.scrollTo(0, 0)
|
||||
}
|
||||
document.getElementById('main')?.focus({ preventScroll: true })
|
||||
}, [pathname, hash])
|
||||
}
|
||||
|
||||
export const SiteLayout = () => {
|
||||
useNavigationEffects()
|
||||
usePageMeta()
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#main"
|
||||
className="sr-only rounded-md border border-line bg-panel px-4 py-2 focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-100"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Nav />
|
||||
<main id="main" tabIndex={-1} className="outline-none">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useTheme } from '@/theme/use-theme'
|
||||
import type { Theme } from '@/theme/theme-context'
|
||||
|
||||
/*
|
||||
* Cycles system → light → dark → system rather than a plain two-way flip, so
|
||||
* "follow the OS" stays reachable after the first click. The glyph shows what
|
||||
* is painted; the label says which mode is selected.
|
||||
*/
|
||||
const NEXT: Record<Theme, Theme> = {
|
||||
system: 'light',
|
||||
light: 'dark',
|
||||
dark: 'system',
|
||||
}
|
||||
|
||||
const LABEL: Record<Theme, string> = {
|
||||
system: 'system',
|
||||
light: 'light',
|
||||
dark: 'dark',
|
||||
}
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(NEXT[theme])
|
||||
}}
|
||||
aria-label={`Theme: ${LABEL[theme]}. Switch to ${LABEL[NEXT[theme]]}.`}
|
||||
title={`Theme: ${LABEL[theme]}`}
|
||||
className="inline-flex h-[38px] items-center justify-center gap-1.5 rounded-md border border-hairline bg-transparent px-2.5 font-mono text-xs text-secondary hover:border-line hover:bg-[var(--bg-selection)] hover:text-primary sm:h-8"
|
||||
>
|
||||
<span aria-hidden="true">{resolvedTheme === 'light' ? '☀' : '☾'}</span>
|
||||
{theme === 'system' && (
|
||||
<span aria-hidden="true" className="text-[10px] opacity-70">
|
||||
auto
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { VERSION } from '@/lib/links'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { CLOCK, DATE, HOST, SESSION, SHORTCUTS, WINDOWS } from './data'
|
||||
|
||||
interface TuiPaneProps {
|
||||
title: string
|
||||
num?: number
|
||||
active?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
bodyClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The TUI's bordered pane: a legend that breaks the top border, green when
|
||||
* the pane has focus. Mirrors `pane_block()` in the Rust client.
|
||||
*/
|
||||
export const TuiPane = ({
|
||||
title,
|
||||
num,
|
||||
active = false,
|
||||
children,
|
||||
className,
|
||||
bodyClassName,
|
||||
}: TuiPaneProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative border bg-base',
|
||||
active ? 'border-[var(--color-phosphor)]' : 'border-[color-mix(in_srgb,var(--color-dust)_45%,transparent)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -top-[9px] left-3 bg-base px-1.5 text-[12.5px] whitespace-nowrap',
|
||||
active ? 'text-[var(--color-phosphor)]' : 'text-[var(--color-dust)]',
|
||||
)}
|
||||
>
|
||||
{num !== undefined && <span className="text-[var(--color-signal)]">[{num}] </span>}
|
||||
{title}
|
||||
</span>
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
/** The macOS window bezel the whole demo sits in. */
|
||||
export const MacWindow = ({ children }: { children: React.ReactNode }) => (
|
||||
<div
|
||||
className="w-[1360px] overflow-hidden rounded-xl border bg-base font-mono tabular-nums"
|
||||
style={{ borderColor: 'var(--tui-bezel)' }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const TmuxBar = () => (
|
||||
<div className="flex h-[26px] items-center border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] bg-[var(--tmux-bg)] text-[12.5px] text-[var(--color-phosphor)]">
|
||||
<span className="flex h-full items-center bg-[var(--color-phosphor)] px-2.5 font-bold text-[var(--tmux-bg)]">
|
||||
{SESSION}
|
||||
</span>
|
||||
{WINDOWS.map((w) => (
|
||||
<span
|
||||
key={w.n}
|
||||
className={cn(
|
||||
'flex h-full items-center gap-1.5 px-3',
|
||||
w.active === true
|
||||
? 'bg-[var(--tmux-hl)] font-bold text-[var(--tmux-hl-fg)]'
|
||||
: 'text-[var(--color-phosphor)]',
|
||||
)}
|
||||
>
|
||||
{w.n} <span className="opacity-55">›</span> {w.name}
|
||||
</span>
|
||||
))}
|
||||
<span className="ml-auto flex gap-4 px-3 text-[var(--color-paper)]">
|
||||
<span className="bg-[var(--tmux-hl)] px-2 font-semibold text-[var(--tmux-hl-fg)]">
|
||||
{DATE} · {CLOCK}
|
||||
</span>
|
||||
<span className="font-bold">{HOST}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface StatusCounts {
|
||||
active: number
|
||||
done: number
|
||||
fail: number
|
||||
queued: number
|
||||
}
|
||||
|
||||
interface StatusLineProps {
|
||||
counts: StatusCounts
|
||||
pct: number
|
||||
}
|
||||
|
||||
export const StatusLine = ({ counts, pct }: StatusLineProps) => (
|
||||
<TuiPane title="Status" bodyClassName="px-3 py-2">
|
||||
<div className="flex items-center gap-3.5 text-[13px] whitespace-nowrap text-[var(--color-paper)]">
|
||||
<span>
|
||||
<b className="font-semibold">soulseek-rs</b> 🦀 {VERSION}
|
||||
</span>
|
||||
<span className="text-[var(--color-dust)]">
|
||||
Downloads: <span className="text-[var(--color-oxide)]">{counts.active} active</span>
|
||||
, <span className="text-[var(--color-phosphor)]">{counts.done} completed</span>,{' '}
|
||||
<span className="text-[var(--color-alarm)]">{counts.fail} failed</span>,{' '}
|
||||
<span className="text-[var(--color-tape)]">{counts.queued} queued</span>, 0 paused
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2.5 text-[var(--color-paper)]">
|
||||
67.7/205.2 MB · 33.38 MB/s
|
||||
<span className="relative inline-block h-[13px] w-[340px] border border-[var(--tm-bd)] bg-[var(--tui-track)]">
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 bg-[var(--color-signal)] opacity-55 transition-[width] duration-300"
|
||||
style={{ width: `${String(pct)}%` }}
|
||||
/>
|
||||
</span>
|
||||
<b>{pct}%</b>
|
||||
</span>
|
||||
</div>
|
||||
</TuiPane>
|
||||
)
|
||||
|
||||
export const ShortcutBar = ({ share }: { share: string }) => (
|
||||
<TuiPane title="Shortcuts · Sharing" className="mt-2.5" bodyClassName="px-3 py-2">
|
||||
<div className="mb-1.5 text-xs text-[var(--color-dust)]">
|
||||
Sharing: <span className="text-[var(--color-phosphor)]">{share}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3.5 text-[12.5px] text-[var(--color-dust)]">
|
||||
{SHORTCUTS.map(([key, action]) => (
|
||||
<span key={key}>
|
||||
[<span className="text-[var(--color-signal)]">{key}</span> → {action}]
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</TuiPane>
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Sample data for the TUI recreation in the hero.
|
||||
*
|
||||
* Everything here is invented, public-domain, or Creative Commons. No real
|
||||
* artist, album, or track names — see TONE-OF-VOICE.md §5 "Banned for legal
|
||||
* reasons". Anything added here has to keep that true.
|
||||
*/
|
||||
|
||||
export interface SearchRow {
|
||||
id: string
|
||||
query: string
|
||||
results: number | string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ResultRow {
|
||||
name: string
|
||||
size: string
|
||||
user: string
|
||||
bitrate: string
|
||||
speed: string
|
||||
slots: number
|
||||
/** Marked for download with Space, not yet queued. */
|
||||
marked?: boolean
|
||||
/** Handed to the transfer list. */
|
||||
queued?: boolean
|
||||
}
|
||||
|
||||
export type TransferStatus = 'queued' | 'active' | 'done' | 'fail'
|
||||
|
||||
export interface TransferRow {
|
||||
id: string
|
||||
status: TransferStatus
|
||||
name: string
|
||||
user: string
|
||||
progress: string
|
||||
speed: string
|
||||
/** Present only on transfers the demo drives; the static rows leave it out. */
|
||||
pct?: number
|
||||
sizeMb?: number
|
||||
rateMbps?: number
|
||||
}
|
||||
|
||||
interface TmuxWindow {
|
||||
n: number
|
||||
name: string
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
export const HOST = 'michels-macbook-pro-m4'
|
||||
export const CLOCK = '01:45 PM'
|
||||
export const DATE = '2026-07-24'
|
||||
export const SESSION = 'develop'
|
||||
export const SHARE = '/Users/micheldegraaf/Downloads/Soulseek'
|
||||
|
||||
export const WINDOWS: readonly TmuxWindow[] = [
|
||||
{ n: 1, name: 'fish #' },
|
||||
{ n: 2, name: 'fish #' },
|
||||
{ n: 3, name: 'fish #' },
|
||||
{ n: 4, name: 'fish #-' },
|
||||
{ n: 5, name: 'fish *', active: true },
|
||||
]
|
||||
|
||||
export const SEARCHES: readonly SearchRow[] = [
|
||||
{ id: 's1', query: 'public domain field recordings', results: 606, status: 'Done' },
|
||||
{ id: 's2', query: 'creative commons ambient', results: 33, status: 'Done' },
|
||||
{ id: 's3', query: 'netlabel 2004', results: 608, status: 'Done' },
|
||||
]
|
||||
|
||||
const pd = (name: string, size: string, bitrate: string): ResultRow => ({
|
||||
name: `shared\\archive\\PD Field Recordings\\${name}`,
|
||||
size,
|
||||
user: 'loopdigger',
|
||||
bitrate,
|
||||
speed: '3.35 MB/s',
|
||||
slots: 1,
|
||||
})
|
||||
|
||||
export const RESULTS: Readonly<Record<string, readonly ResultRow[]>> = {
|
||||
s1: [
|
||||
pd('01 - Coastal Dawn (Sunrise Take).flac', '30.5 MB', '675 kbps'),
|
||||
pd('02 - Reservoir Wind.flac', '22.6 MB', '569 kbps'),
|
||||
pd('cover.jpg', '0.0 MB', '-'),
|
||||
pd('03 - Marsh at Night (Long).flac', '48.0 MB', '920 kbps'),
|
||||
pd('04 - Tram Depot, 06h.flac', '32.2 MB', '725 kbps'),
|
||||
pd('05 - Harbour Bells.flac', '52.5 MB', '902 kbps'),
|
||||
pd('06 - Rain on Zinc Roof.flac', '30.4 MB', '804 kbps'),
|
||||
pd('07 - Empty Chapel Tone.flac', '17.4 MB', '836 kbps'),
|
||||
pd('08 - Forest Floor.flac', '38.0 MB', '741 kbps'),
|
||||
pd('09 - Quarry Echo.flac', '34.6 MB', '753 kbps'),
|
||||
pd('10 - Night Ferry.flac', '18.7 MB', '723 kbps'),
|
||||
pd('11 - Last Train.flac', '19.5 MB', '621 kbps'),
|
||||
{
|
||||
name: 'shared\\netlabel\\Explorers of the Interior [CC-BY]\\readme.txt',
|
||||
size: '1.0 MB',
|
||||
user: 'loopdigger',
|
||||
bitrate: '128 kbps',
|
||||
speed: '3.35 MB/s',
|
||||
slots: 1,
|
||||
},
|
||||
],
|
||||
s2: [
|
||||
{
|
||||
name: 'shared\\cc\\Ambient Works [CC0]\\01 - Slow Meadow.flac',
|
||||
size: '41.2 MB',
|
||||
user: 'tapehiss',
|
||||
bitrate: '911 kbps',
|
||||
speed: '2.10 MB/s',
|
||||
slots: 2,
|
||||
},
|
||||
{
|
||||
name: 'shared\\cc\\Ambient Works [CC0]\\02 - Held Note.flac',
|
||||
size: '28.9 MB',
|
||||
user: 'tapehiss',
|
||||
bitrate: '740 kbps',
|
||||
speed: '2.10 MB/s',
|
||||
slots: 2,
|
||||
},
|
||||
{
|
||||
name: 'shared\\cc\\Ambient Works [CC0]\\03 - Grain.flac',
|
||||
size: '33.1 MB',
|
||||
user: 'tapehiss',
|
||||
bitrate: '812 kbps',
|
||||
speed: '2.10 MB/s',
|
||||
slots: 2,
|
||||
},
|
||||
],
|
||||
s3: [
|
||||
{
|
||||
name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\01 - Opener.mp3',
|
||||
size: '9.4 MB',
|
||||
user: 'nectoUA',
|
||||
bitrate: '320 kbps',
|
||||
speed: '1.05 MB/s',
|
||||
slots: 0,
|
||||
},
|
||||
{
|
||||
name: 'shared\\netlabel\\2004 Comp [CC-BY-NC]\\02 - Interlude.mp3',
|
||||
size: '5.1 MB',
|
||||
user: 'nectoUA',
|
||||
bitrate: '320 kbps',
|
||||
speed: '1.05 MB/s',
|
||||
slots: 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const TRANSFERS: readonly TransferRow[] = [
|
||||
{
|
||||
id: 'x1',
|
||||
status: 'done',
|
||||
name: '@@lrfkq\\Contents\\Rotterdam',
|
||||
user: 'Dan Dillion',
|
||||
progress: 'Completed',
|
||||
speed: '-',
|
||||
},
|
||||
{
|
||||
id: 'x2',
|
||||
status: 'done',
|
||||
name: '@@lrfkq\\Contents\\Journal',
|
||||
user: 'Dan Dillion',
|
||||
progress: 'Completed',
|
||||
speed: '-',
|
||||
},
|
||||
{
|
||||
id: 'x3',
|
||||
status: 'active',
|
||||
name: '@@lrfkq\\Contents\\Context',
|
||||
user: 'Dan Dillion',
|
||||
progress: '37.5 MB/53.4 MB (70%)',
|
||||
speed: '12.4 MB/s',
|
||||
},
|
||||
{
|
||||
id: 'x4',
|
||||
status: 'fail',
|
||||
name: '@@gufoo\\put all musics',
|
||||
user: 'publicdomainbot',
|
||||
progress: 'Failed',
|
||||
speed: '-',
|
||||
},
|
||||
{
|
||||
id: 'x5',
|
||||
status: 'active',
|
||||
name: '02-RawMusic\\Embark Studio',
|
||||
user: 'tapehiss',
|
||||
progress: '10.4 MB/14.3 MB (72%)',
|
||||
speed: '11.5 MB/s',
|
||||
},
|
||||
{
|
||||
id: 'x6',
|
||||
status: 'fail',
|
||||
name: '@@medvw\\Soulseek\\complete',
|
||||
user: 'x100',
|
||||
progress: 'Failed',
|
||||
speed: '-',
|
||||
},
|
||||
{
|
||||
id: 'x7',
|
||||
status: 'active',
|
||||
name: '@@hnttf\\complete\\(2004 netlabel)',
|
||||
user: 'nectoUA',
|
||||
progress: '10.5 MB/25.5 MB (41%)',
|
||||
speed: '9.3 MB/s',
|
||||
},
|
||||
]
|
||||
|
||||
export const SHORTCUTS: readonly (readonly [string, string])[] = [
|
||||
['Space', 'select'],
|
||||
['Enter', 'download'],
|
||||
['b', 'browse owner'],
|
||||
['c', 'chat'],
|
||||
['/', 'filter'],
|
||||
['a/A', 'select all/none'],
|
||||
['1-3', 'focus pane'],
|
||||
['q', 'quit'],
|
||||
]
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
|
||||
import { MacWindow, ShortcutBar, StatusLine, TmuxBar, type StatusCounts } from './chrome'
|
||||
import {
|
||||
RESULTS,
|
||||
SEARCHES,
|
||||
SHARE,
|
||||
TRANSFERS,
|
||||
type ResultRow,
|
||||
type SearchRow,
|
||||
type TransferRow,
|
||||
} from './data'
|
||||
import { InfoPane, ResultsPane, SearchesPane, TransfersPane } from './panes'
|
||||
import { useFitScale } from './use-fit-scale'
|
||||
|
||||
const DESIGN_WIDTH = 1360
|
||||
const TICK_MS = 480
|
||||
const MAX_CONCURRENT = 2
|
||||
/** Where the scripted run leaves the cursor. */
|
||||
const RESTING_ROW = 5
|
||||
|
||||
type Phase = 'searching' | 'results' | 'interactive'
|
||||
|
||||
/** Pages are prerendered in Node, where there is no window. */
|
||||
const isBrowser = typeof window !== 'undefined'
|
||||
|
||||
const matches = (query: string): boolean =>
|
||||
isBrowser && window.matchMedia(query).matches
|
||||
|
||||
const prefersReducedMotion = (): boolean => matches('(prefers-reduced-motion: reduce)')
|
||||
|
||||
const sizeMb = (size: string): number => {
|
||||
const parsed = Number.parseFloat(size)
|
||||
return Number.isNaN(parsed) || parsed === 0 ? 12 : parsed
|
||||
}
|
||||
|
||||
/*
|
||||
* Deterministic on purpose: the id is derived from the filename and the rate
|
||||
* from its length, so queueing the same row twice is a no-op and nothing
|
||||
* random runs inside a state updater. Both matter under StrictMode, which
|
||||
* double-invokes updaters in development.
|
||||
*/
|
||||
const toTransfer = (row: ResultRow): TransferRow => ({
|
||||
id: `t:${row.name}`,
|
||||
status: 'queued',
|
||||
name: row.name,
|
||||
user: row.user,
|
||||
progress: 'Queued',
|
||||
speed: '-',
|
||||
pct: 0,
|
||||
sizeMb: sizeMb(row.size),
|
||||
rateMbps: 1.4 + (row.name.length % 7) * 0.4,
|
||||
})
|
||||
|
||||
/**
|
||||
* The hero: a working recreation of the TUI.
|
||||
*
|
||||
* It plays one scripted run (search → results → queue two files) and then
|
||||
* hands over to the keyboard. Only transfers carrying a `pct` are simulated;
|
||||
* the seeded rows stay put so the pane looks lived-in from the first frame.
|
||||
*
|
||||
* Under `prefers-reduced-motion` it starts at the end state instead, so the
|
||||
* page never animates at someone who asked it not to.
|
||||
*/
|
||||
export const HeroTerminal = () => {
|
||||
const [searches, setSearches] = useState<readonly SearchRow[]>(SEARCHES)
|
||||
const [results, setResults] =
|
||||
useState<Readonly<Record<string, readonly ResultRow[]>>>(RESULTS)
|
||||
const [activeId, setActiveId] = useState('s1')
|
||||
const [transfers, setTransfers] = useState<readonly TransferRow[]>(TRANSFERS)
|
||||
const [focus, setFocus] = useState(2)
|
||||
|
||||
// Reduced motion skips the performance, so the resting state is the initial
|
||||
// state. Resolved once, in the initialiser, to keep it out of an effect.
|
||||
const [phase, setPhase] = useState<Phase>(() =>
|
||||
prefersReducedMotion() ? 'interactive' : 'searching',
|
||||
)
|
||||
const [selected, setSelected] = useState(() =>
|
||||
prefersReducedMotion() ? RESTING_ROW : 0,
|
||||
)
|
||||
|
||||
const shellRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Mirrors `results` so queueFrom can read the latest rows without taking a
|
||||
// dependency on them. Synced after commit, which is soon enough: every
|
||||
// reader is a timer or an event handler, never a render.
|
||||
const resultsRef = useRef(results)
|
||||
useEffect(() => {
|
||||
resultsRef.current = results
|
||||
}, [results])
|
||||
|
||||
const rows = useMemo(() => results[activeId] ?? [], [results, activeId])
|
||||
const activeSearch = searches.find((search) => search.id === activeId)
|
||||
|
||||
const counts = useMemo<StatusCounts>(() => {
|
||||
const tally: StatusCounts = { active: 0, done: 0, fail: 0, queued: 0 }
|
||||
for (const transfer of transfers) tally[transfer.status] += 1
|
||||
return tally
|
||||
}, [transfers])
|
||||
|
||||
// Derived, not stored: the status bar is a pure function of what is moving.
|
||||
const pct = useMemo(() => {
|
||||
const live = transfers.filter(
|
||||
(t) => t.pct !== undefined && (t.status === 'active' || t.status === 'done'),
|
||||
)
|
||||
if (live.length === 0) return 0
|
||||
return Math.round(live.reduce((sum, t) => sum + (t.pct ?? 0), 0) / live.length)
|
||||
}, [transfers])
|
||||
|
||||
/** True while any simulated transfer still has somewhere to go. */
|
||||
const hasWork = useMemo(
|
||||
() => transfers.some((t) => t.pct !== undefined && t.status !== 'done'),
|
||||
[transfers],
|
||||
)
|
||||
|
||||
// Stops once everything has finished. Without the `hasWork` gate the timer
|
||||
// would tick twice a second for as long as the tab is open, re-rendering
|
||||
// the whole TUI to produce identical output.
|
||||
useEffect(() => {
|
||||
if (!hasWork || prefersReducedMotion()) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setTransfers((list) => {
|
||||
let live = list.filter((t) => t.status === 'active' && t.pct !== undefined).length
|
||||
|
||||
const next = list.map((t) => {
|
||||
if (t.pct === undefined) return t
|
||||
const total = t.sizeMb ?? 12
|
||||
const rate = t.rateMbps ?? 2
|
||||
|
||||
if (t.status === 'queued' && live < MAX_CONCURRENT) {
|
||||
live += 1
|
||||
return {
|
||||
...t,
|
||||
status: 'active' as const,
|
||||
speed: `${rate.toFixed(2)} MB/s`,
|
||||
progress: `0.0/${total.toFixed(1)} MB (0%)`,
|
||||
}
|
||||
}
|
||||
if (t.status !== 'active') return t
|
||||
|
||||
// Step size varies per file but is a pure function of it, so the
|
||||
// updater stays free of side effects under StrictMode's double call.
|
||||
const step = 4 + ((t.name.length + Math.round(t.pct)) % 9)
|
||||
const pct = Math.min(100, t.pct + step)
|
||||
const finished = pct >= 100
|
||||
return {
|
||||
...t,
|
||||
status: finished ? ('done' as const) : ('active' as const),
|
||||
pct,
|
||||
speed: finished ? '-' : `${rate.toFixed(2)} MB/s`,
|
||||
progress: finished
|
||||
? 'Complete'
|
||||
: `${((total * pct) / 100).toFixed(1)}/${total.toFixed(1)} MB (${String(Math.round(pct))}%)`,
|
||||
}
|
||||
})
|
||||
|
||||
// map() always allocates, so without this every tick would hand React
|
||||
// a fresh array and force a re-render that produces identical output.
|
||||
// Returning the original lets React bail out instead.
|
||||
const moved = next.some((t, i) => t !== list[i])
|
||||
return moved ? next : list
|
||||
})
|
||||
}, TICK_MS)
|
||||
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [hasWork])
|
||||
|
||||
/*
|
||||
* Queue rows by index within a named search.
|
||||
*
|
||||
* Reads the current rows through a ref rather than closing over `results`,
|
||||
* so the callback identity never changes — the opening script can then
|
||||
* depend on it without restarting itself every time state moves.
|
||||
*/
|
||||
const queueFrom = useCallback((searchId: string, indexes: readonly number[]) => {
|
||||
if (indexes.length === 0) return
|
||||
|
||||
const current = resultsRef.current[searchId] ?? []
|
||||
const picked = indexes
|
||||
.map((i) => current[i])
|
||||
.filter((row): row is ResultRow => row !== undefined && row.queued !== true)
|
||||
if (picked.length === 0) return
|
||||
|
||||
const wanted = new Set(picked.map((row) => row.name))
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[searchId]: (prev[searchId] ?? []).map((row) =>
|
||||
wanted.has(row.name) ? { ...row, queued: true, marked: false } : row,
|
||||
),
|
||||
}))
|
||||
setTransfers((list) => {
|
||||
const seen = new Set(list.map((transfer) => transfer.id))
|
||||
const additions = picked.map(toTransfer).filter((t) => !seen.has(t.id))
|
||||
return additions.length === 0 ? list : [...additions, ...list]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const queueIndexes = useCallback(
|
||||
(indexes: readonly number[]) => {
|
||||
queueFrom(activeId, indexes)
|
||||
},
|
||||
[queueFrom, activeId],
|
||||
)
|
||||
|
||||
const toggleMark = useCallback(
|
||||
(index: number) => {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[activeId]: (prev[activeId] ?? []).map((row, i) =>
|
||||
i === index && row.queued !== true ? { ...row, marked: row.marked !== true } : row,
|
||||
),
|
||||
}))
|
||||
},
|
||||
[activeId],
|
||||
)
|
||||
|
||||
const markAll = useCallback(
|
||||
(on: boolean) => {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[activeId]: (prev[activeId] ?? []).map((row) =>
|
||||
row.queued === true ? row : { ...row, marked: on },
|
||||
),
|
||||
}))
|
||||
},
|
||||
[activeId],
|
||||
)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.target instanceof HTMLInputElement) return
|
||||
const list = results[activeId] ?? []
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
case 'j':
|
||||
event.preventDefault()
|
||||
setFocus(2)
|
||||
setSelected((s) => Math.min(list.length - 1, s + 1))
|
||||
return
|
||||
case 'ArrowUp':
|
||||
case 'k':
|
||||
event.preventDefault()
|
||||
setFocus(2)
|
||||
setSelected((s) => Math.max(0, s - 1))
|
||||
return
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
setFocus(2)
|
||||
toggleMark(selected)
|
||||
return
|
||||
case 'Enter': {
|
||||
event.preventDefault()
|
||||
const marked = list.flatMap((row, i) =>
|
||||
row.marked === true && row.queued !== true ? [i] : [],
|
||||
)
|
||||
queueIndexes(marked.length > 0 ? marked : [selected])
|
||||
setFocus(3)
|
||||
return
|
||||
}
|
||||
case 'a':
|
||||
event.preventDefault()
|
||||
markAll(true)
|
||||
return
|
||||
case 'A':
|
||||
event.preventDefault()
|
||||
markAll(false)
|
||||
return
|
||||
case '1':
|
||||
case '2':
|
||||
case '3':
|
||||
event.preventDefault()
|
||||
setFocus(Number(event.key))
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The scripted opening run.
|
||||
*
|
||||
* No "has this already started" ref: the cleanup cancels every timer, so a
|
||||
* StrictMode remount simply replays from the top. A ref guard would let the
|
||||
* first cleanup cancel the run and the re-run skip scheduling it, leaving
|
||||
* the demo frozen on its first frame in development.
|
||||
*
|
||||
* It targets 's1' directly rather than whatever is selected, because the
|
||||
* script only ever describes the opening state.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) return
|
||||
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
const at = (fn: () => void, ms: number) => timers.push(setTimeout(fn, ms))
|
||||
const mark = (i: number) => {
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
s1: (prev['s1'] ?? []).map((row, ix) => (ix === i ? { ...row, marked: true } : row)),
|
||||
}))
|
||||
}
|
||||
|
||||
at(() => {
|
||||
setPhase('results')
|
||||
}, 1050)
|
||||
at(() => {
|
||||
setSelected(3)
|
||||
}, 1550)
|
||||
at(() => {
|
||||
setSelected(7)
|
||||
}, 2050)
|
||||
at(() => {
|
||||
setSelected(10)
|
||||
}, 2550)
|
||||
at(() => {
|
||||
mark(10)
|
||||
}, 2850)
|
||||
at(() => {
|
||||
queueFrom('s1', [10])
|
||||
}, 3050)
|
||||
at(() => {
|
||||
setSelected(3)
|
||||
}, 3950)
|
||||
at(() => {
|
||||
mark(3)
|
||||
}, 4150)
|
||||
at(() => {
|
||||
queueFrom('s1', [3])
|
||||
}, 4350)
|
||||
at(() => {
|
||||
setSelected(RESTING_ROW)
|
||||
setPhase('interactive')
|
||||
}, 5050)
|
||||
|
||||
return () => {
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}, [queueFrom])
|
||||
|
||||
const submitSearch = (query: string) => {
|
||||
const id = `s${String(searches.length + 1)}`
|
||||
setSearches((prev) => [
|
||||
...prev,
|
||||
{ id, query, results: 40 + Math.floor(Math.random() * 900), status: 'Done' },
|
||||
])
|
||||
setResults((prev) => ({
|
||||
...prev,
|
||||
[id]: Array.from({ length: 6 }, (_, i) => ({
|
||||
name: `shared\\results\\${query.replace(/[^a-z0-9]+/gi, '_')}\\0${String(i + 1)} - track.flac`,
|
||||
size: `${(10 + i * 7).toFixed(1)} MB`,
|
||||
user: ['loopdigger', 'tapehiss', 'nectoUA'][i % 3] ?? 'loopdigger',
|
||||
bitrate: `${String(500 + i * 60)} kbps`,
|
||||
speed: '2.20 MB/s',
|
||||
slots: i % 2,
|
||||
})),
|
||||
}))
|
||||
setActiveId(id)
|
||||
setSelected(0)
|
||||
setFocus(2)
|
||||
setPhase('interactive')
|
||||
}
|
||||
|
||||
// Below 820px, stop shrinking at half size and let the wrapper scroll.
|
||||
const [minScale, setMinScale] = useState(() =>
|
||||
matches('(max-width: 820px)') ? 0.5 : 0,
|
||||
)
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia('(max-width: 820px)')
|
||||
const sync = (event: MediaQueryListEvent) => {
|
||||
setMinScale(event.matches ? 0.5 : 0)
|
||||
}
|
||||
query.addEventListener('change', sync)
|
||||
return () => {
|
||||
query.removeEventListener('change', sync)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { wrapRef, innerRef, scale, height, ready } = useFitScale(DESIGN_WIDTH, minScale)
|
||||
|
||||
const shownSearches = searches.map((search, i) =>
|
||||
i === 0 && phase === 'searching' ? { ...search, status: '⋯', results: '—' } : search,
|
||||
)
|
||||
const shownRows = phase === 'searching' ? [] : rows
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className="w-full overflow-x-auto overflow-y-hidden"
|
||||
style={ready ? { height } : undefined}
|
||||
>
|
||||
<div style={ready ? { width: DESIGN_WIDTH * scale, height } : undefined}>
|
||||
<div
|
||||
ref={innerRef}
|
||||
className="origin-top-left"
|
||||
style={{
|
||||
width: DESIGN_WIDTH,
|
||||
transform: ready ? `scale(${String(scale)})` : undefined,
|
||||
visibility: ready ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<MacWindow>
|
||||
<TmuxBar />
|
||||
{/*
|
||||
role="application" is the ARIA escape hatch for a widget that
|
||||
handles its own keys: it stops screen readers intercepting
|
||||
j/k/arrows so the demo behaves like the real TUI. Every control
|
||||
inside is still a native button or input.
|
||||
*/}
|
||||
<div
|
||||
ref={shellRef}
|
||||
tabIndex={0}
|
||||
role="application"
|
||||
aria-label="Interactive soulseek-rs terminal demo"
|
||||
onKeyDown={onKeyDown}
|
||||
className="tui-content p-2.5 outline-none"
|
||||
>
|
||||
<StatusLine counts={counts} pct={pct} />
|
||||
|
||||
<div className="mt-3 grid gap-2.5" style={{ gridTemplateColumns: '300px 1fr' }}>
|
||||
<div className="min-h-[498px]">
|
||||
<SearchesPane
|
||||
searches={shownSearches}
|
||||
activeId={activeId}
|
||||
active={focus === 1}
|
||||
onPick={(id) => {
|
||||
setActiveId(id)
|
||||
setSelected(0)
|
||||
setFocus(1)
|
||||
setPhase('interactive')
|
||||
}}
|
||||
onSubmit={submitSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 gap-2.5" style={{ gridTemplateRows: '1fr 300px' }}>
|
||||
<div className="relative min-h-[250px] overflow-hidden">
|
||||
<ResultsPane
|
||||
query={activeSearch?.query ?? ''}
|
||||
rows={shownRows}
|
||||
selected={selected}
|
||||
active={focus === 2}
|
||||
onSelect={(i) => {
|
||||
setSelected(i)
|
||||
setFocus(2)
|
||||
}}
|
||||
onQueue={(i) => {
|
||||
queueIndexes([i])
|
||||
setFocus(3)
|
||||
}}
|
||||
/>
|
||||
{phase === 'searching' && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[color-mix(in_srgb,var(--color-sleeve)_35%,transparent)] text-[13.5px] tracking-[0.02em] text-[var(--color-dust)]">
|
||||
searching the network
|
||||
<span className="ml-1.5 inline-block h-[15px] w-2 translate-y-[2px] rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="grid min-h-0 gap-2.5"
|
||||
style={{ gridTemplateColumns: '1fr 340px' }}
|
||||
>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<TransfersPane rows={transfers.slice(0, 7)} active={focus === 3} />
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 overflow-hidden">
|
||||
<InfoPane row={phase === 'searching' ? undefined : rows[selected]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShortcutBar share={SHARE} />
|
||||
</div>
|
||||
</MacWindow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useState, type SyntheticEvent } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { TuiPane } from './chrome'
|
||||
import type { ResultRow, SearchRow, TransferRow, TransferStatus } from './data'
|
||||
|
||||
const RESULT_COLS = '30px 1fr 74px 92px 78px 74px 42px'
|
||||
const TRANSFER_COLS = '48px 1fr 96px 150px 74px'
|
||||
|
||||
interface SearchesPaneProps {
|
||||
searches: readonly SearchRow[]
|
||||
activeId: string
|
||||
active: boolean
|
||||
onPick: (id: string) => void
|
||||
onSubmit: (query: string) => void
|
||||
}
|
||||
|
||||
export const SearchesPane = ({
|
||||
searches,
|
||||
activeId,
|
||||
active,
|
||||
onPick,
|
||||
onSubmit,
|
||||
}: SearchesPaneProps) => {
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const submit = (event: SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
|
||||
event.preventDefault()
|
||||
const trimmed = query.trim()
|
||||
if (trimmed === '') return
|
||||
onSubmit(trimmed)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<TuiPane
|
||||
title="Searches"
|
||||
num={1}
|
||||
active={active}
|
||||
className="h-full"
|
||||
bodyClassName="px-3 pt-4 pb-3"
|
||||
>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="mb-3 flex items-center gap-1.5 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] pb-2.5"
|
||||
>
|
||||
<span className="text-[13px] text-[var(--color-oxide)]">/</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value)
|
||||
}}
|
||||
placeholder="search the network…"
|
||||
aria-label="Search the network"
|
||||
className="grow border-0 bg-transparent font-mono text-[13px] text-[var(--color-paper)] outline-none placeholder:text-[var(--color-dust)]"
|
||||
/>
|
||||
<span className="h-[15px] w-2 rounded-[1.5px] bg-[var(--color-oxide)] motion-safe:animate-cursor-blink" />
|
||||
</form>
|
||||
|
||||
<div
|
||||
className="grid px-1 pb-1.5 text-xs text-[var(--color-dust)]"
|
||||
style={{ gridTemplateColumns: '56px 1fr 58px' }}
|
||||
>
|
||||
<span>Status</span>
|
||||
<span>Query</span>
|
||||
<span className="text-right">Results</span>
|
||||
</div>
|
||||
|
||||
{searches.map((search) => (
|
||||
<button
|
||||
type="button"
|
||||
key={search.id}
|
||||
onClick={() => {
|
||||
onPick(search.id)
|
||||
}}
|
||||
className={cn(
|
||||
'grid w-full cursor-pointer p-1 text-left text-[13px] text-[var(--color-paper)]',
|
||||
search.id === activeId && 'bg-[color-mix(in_srgb,var(--color-signal)_14%,transparent)]',
|
||||
)}
|
||||
style={{ gridTemplateColumns: '56px 1fr 58px' }}
|
||||
>
|
||||
<span className="text-[var(--color-phosphor)]">
|
||||
{search.id === activeId ? '→' : ' '}
|
||||
{search.status}
|
||||
</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{search.query}
|
||||
</span>
|
||||
<span className="text-right text-[var(--color-dust)]">{search.results}</span>
|
||||
</button>
|
||||
))}
|
||||
</TuiPane>
|
||||
)
|
||||
}
|
||||
|
||||
const resultGlyph = (row: ResultRow): { glyph: string; tone: string } => {
|
||||
if (row.queued === true) return { glyph: '✓', tone: 'text-[var(--color-phosphor)]' }
|
||||
if (row.marked === true) return { glyph: '[✓]', tone: 'text-[var(--color-oxide)]' }
|
||||
return { glyph: '[ ]', tone: 'text-[var(--color-dust)]' }
|
||||
}
|
||||
|
||||
interface ResultsPaneProps {
|
||||
query: string
|
||||
rows: readonly ResultRow[]
|
||||
selected: number
|
||||
active: boolean
|
||||
onSelect: (index: number) => void
|
||||
onQueue: (index: number) => void
|
||||
}
|
||||
|
||||
export const ResultsPane = ({
|
||||
query,
|
||||
rows,
|
||||
selected,
|
||||
active,
|
||||
onSelect,
|
||||
onQueue,
|
||||
}: ResultsPaneProps) => (
|
||||
<TuiPane
|
||||
title={`Results: ${query}`}
|
||||
num={2}
|
||||
active={active}
|
||||
className="flex h-full flex-col"
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
|
||||
>
|
||||
<div
|
||||
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
|
||||
style={{ gridTemplateColumns: RESULT_COLS }}
|
||||
>
|
||||
<span>✓</span>
|
||||
<span>Filename</span>
|
||||
<span>Size</span>
|
||||
<span>User</span>
|
||||
<span>Bitrate</span>
|
||||
<span>Speed</span>
|
||||
<span>Slots</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{rows.map((row, i) => {
|
||||
const { glyph, tone } = resultGlyph(row)
|
||||
const isSelected = selected === i
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={row.name}
|
||||
onClick={() => {
|
||||
onSelect(i)
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
onQueue(i)
|
||||
}}
|
||||
className={cn(
|
||||
'grid w-full cursor-pointer gap-2 px-3.5 py-[3px] text-left text-[12.5px] text-[var(--color-paper)]',
|
||||
isSelected && 'bg-[color-mix(in_srgb,var(--color-oxide)_16%,transparent)]',
|
||||
)}
|
||||
style={{ gridTemplateColumns: RESULT_COLS }}
|
||||
>
|
||||
<span className={cn('whitespace-nowrap', tone)}>{glyph}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
isSelected ? 'text-[var(--color-paper)]' : 'text-[var(--color-paper-dim)]',
|
||||
)}
|
||||
>
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="text-right text-[var(--color-tape)]">{row.size}</span>
|
||||
<span className="text-[var(--color-signal)]">{row.user}</span>
|
||||
<span className="text-[var(--color-dust)]">{row.bitrate}</span>
|
||||
<span className="text-[var(--color-dust)]">{row.speed}</span>
|
||||
<span className="text-center text-[var(--color-dust)]">{row.slots}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TuiPane>
|
||||
)
|
||||
|
||||
const TRANSFER_GLYPH: Record<TransferStatus, readonly [string, string]> = {
|
||||
queued: ['⋯', 'text-[var(--color-tape)]'],
|
||||
done: ['✓', 'text-[var(--color-phosphor)]'],
|
||||
active: ['↯', 'text-[var(--color-oxide)]'],
|
||||
fail: ['✗', 'text-[var(--color-alarm)]'],
|
||||
}
|
||||
|
||||
const PROGRESS_TONE: Record<TransferStatus, string> = {
|
||||
queued: 'text-[var(--color-paper)]',
|
||||
active: 'text-[var(--color-paper)]',
|
||||
done: 'text-[var(--color-phosphor)]',
|
||||
fail: 'text-[var(--color-alarm)]',
|
||||
}
|
||||
|
||||
interface TransfersPaneProps {
|
||||
rows: readonly TransferRow[]
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export const TransfersPane = ({ rows, active }: TransfersPaneProps) => (
|
||||
<TuiPane
|
||||
title="Downloads/Uploads"
|
||||
num={3}
|
||||
active={active}
|
||||
className="flex h-full flex-col"
|
||||
bodyClassName="flex min-h-0 flex-1 flex-col pt-4"
|
||||
>
|
||||
<div
|
||||
className="grid gap-2 border-b border-[color-mix(in_srgb,var(--color-dust)_25%,transparent)] px-3.5 pb-2 text-xs text-[var(--color-dust)]"
|
||||
style={{ gridTemplateColumns: TRANSFER_COLS }}
|
||||
>
|
||||
<span>Status</span>
|
||||
<span>Filename</span>
|
||||
<span>User</span>
|
||||
<span>Progress</span>
|
||||
<span>Speed</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{rows.map((row) => {
|
||||
const [glyph, tone] = TRANSFER_GLYPH[row.status]
|
||||
return (
|
||||
<div
|
||||
key={row.id}
|
||||
className="grid gap-2 px-3.5 py-[3px] text-[12.5px] text-[var(--color-paper-dim)]"
|
||||
style={{ gridTemplateColumns: TRANSFER_COLS }}
|
||||
>
|
||||
<span className={tone}>{glyph}</span>
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{row.name}
|
||||
</span>
|
||||
<span className="text-[var(--color-signal)]">{row.user}</span>
|
||||
<span className={PROGRESS_TONE[row.status]}>{row.progress}</span>
|
||||
<span className="text-[var(--color-tape)]">{row.speed}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</TuiPane>
|
||||
)
|
||||
|
||||
interface InfoPaneProps {
|
||||
row: ResultRow | undefined
|
||||
}
|
||||
|
||||
export const InfoPane = ({ row }: InfoPaneProps) => {
|
||||
const parts = (row?.name ?? '').split('\\')
|
||||
const file = parts.at(-1) ?? '—'
|
||||
const path = parts.slice(0, -1).join('/')
|
||||
const queued = row?.queued === true
|
||||
|
||||
const fields: readonly (readonly [string, string, string])[] = [
|
||||
['User', row?.user ?? '—', 'text-[var(--color-signal)]'],
|
||||
['Size', row?.size ?? '—', 'text-[var(--color-tape)]'],
|
||||
[
|
||||
'Status',
|
||||
queued ? 'Queued' : 'Available',
|
||||
queued ? 'text-[var(--color-tape)]' : 'text-[var(--color-phosphor)]',
|
||||
],
|
||||
['Bitrate', row?.bitrate ?? '-', 'text-[var(--color-paper)]'],
|
||||
]
|
||||
|
||||
return (
|
||||
<TuiPane title="Info" active className="h-full" bodyClassName="px-3.5 pt-4 pb-3.5">
|
||||
<div className="mb-1 text-[13.5px] break-all text-[var(--color-oxide)]">{file}</div>
|
||||
<div className="mb-4 text-xs break-all text-[var(--color-dust)]">
|
||||
{path === '' ? '—' : path}
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-y-[7px] text-[12.5px]"
|
||||
style={{ gridTemplateColumns: '88px 1fr' }}
|
||||
>
|
||||
{fields.map(([key, value, tone]) => (
|
||||
<div key={key} className="contents">
|
||||
<span className="text-[var(--color-dust)]">{key}</span>
|
||||
<span className={cn('break-all', tone)}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="mt-2.5 text-[var(--color-dust)]">Queue position</span>
|
||||
<span className="mt-2.5 text-[var(--color-paper)]">{queued ? '3' : 'unknown'}</span>
|
||||
<span className="text-[var(--color-dust)]">Free slots</span>
|
||||
<span className="text-[var(--color-phosphor)]">
|
||||
{row !== undefined && row.slots > 0 ? `${String(row.slots)} available` : '0'}
|
||||
</span>
|
||||
</div>
|
||||
</TuiPane>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
|
||||
interface FitScale {
|
||||
wrapRef: RefObject<HTMLDivElement | null>
|
||||
innerRef: RefObject<HTMLDivElement | null>
|
||||
scale: number
|
||||
height: number
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales a fixed-width design down to whatever width it is given.
|
||||
*
|
||||
* The TUI is laid out on a 1360px character grid; reflowing it would stop it
|
||||
* looking like a terminal. So it renders at native width and gets a CSS
|
||||
* transform, and the wrapper is told the resulting height so the page below
|
||||
* does not sit under it.
|
||||
*
|
||||
* `minScale` stops the shrink at a floor on small screens — past that the
|
||||
* wrapper scrolls sideways instead, which beats unreadable 4px text.
|
||||
*/
|
||||
export const useFitScale = (designWidth: number, minScale: number): FitScale => {
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const innerRef = useRef<HTMLDivElement>(null)
|
||||
const [{ scale, height }, setState] = useState({ scale: 0, height: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
// ResizeObserver is browser-only; prerendering never reaches this effect.
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
|
||||
const measure = () => {
|
||||
const wrap = wrapRef.current
|
||||
const inner = innerRef.current
|
||||
if (wrap === null || inner === null) return
|
||||
|
||||
const width = wrap.clientWidth
|
||||
if (width === 0) return
|
||||
|
||||
const next = Math.max(minScale, Math.min(1, width / designWidth))
|
||||
const nextHeight = inner.offsetHeight * next
|
||||
if (nextHeight === 0) return
|
||||
|
||||
setState((prev) =>
|
||||
Math.abs(prev.scale - next) < 0.001 && Math.abs(prev.height - nextHeight) < 0.5
|
||||
? prev
|
||||
: { scale: next, height: nextHeight },
|
||||
)
|
||||
}
|
||||
|
||||
measure()
|
||||
|
||||
// Both boxes matter: the wrapper for available width, the inner for the
|
||||
// height that width produces once the webfont has swapped in.
|
||||
const observer = new ResizeObserver(measure)
|
||||
if (wrapRef.current !== null) observer.observe(wrapRef.current)
|
||||
if (innerRef.current !== null) observer.observe(innerRef.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [designWidth, minScale])
|
||||
|
||||
return { wrapRef, innerRef, scale, height, ready: scale > 0 }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center gap-2 rounded-md border font-mono font-medium transition-[background,border-color] duration-[120ms] disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-line bg-transparent text-primary hover:border-line-strong hover:bg-[var(--bg-selection)]',
|
||||
accent:
|
||||
'border-accent bg-accent text-white hover:border-accent-hover hover:bg-accent-hover',
|
||||
},
|
||||
size: {
|
||||
default: 'h-11 px-5 text-sm max-sm:h-[42px] max-sm:px-4 max-sm:text-[13px]',
|
||||
sm: 'h-8 px-2.5 text-xs',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
|
||||
interface ButtonProps extends ButtonVariants {
|
||||
/** Internal route path, or an absolute URL for an off-site link. */
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const isExternal = (href: string): boolean => /^https?:\/\//.test(href)
|
||||
|
||||
/**
|
||||
* Every call to action on the site is a link, so this renders an anchor
|
||||
* rather than a `<button>`. External links open in a new tab and carry
|
||||
* `rel="noopener"`; internal ones route through React Router so navigation
|
||||
* stays client-side.
|
||||
*/
|
||||
export const Button = ({ href, variant, size, children, className }: ButtonProps) => {
|
||||
const classes = cn(buttonVariants({ variant, size }), className)
|
||||
|
||||
if (isExternal(href))
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className={classes}>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
|
||||
return (
|
||||
<Link to={href} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCopy } from '@/hooks/use-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CommandPillProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The one-line install command, click-to-copy. */
|
||||
export const CommandPill = ({ text, className }: CommandPillProps) => {
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy command"
|
||||
onClick={() => {
|
||||
copy(text)
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-11 items-center gap-2.5 rounded-md border border-line bg-panel py-0 pr-2.5 pl-3.5 font-mono text-[13.5px] tabular-nums text-primary hover:border-line-strong hover:bg-raised max-sm:w-full max-sm:justify-between',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="text-accent">
|
||||
$
|
||||
</span>
|
||||
<code className="font-mono">{text}</code>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'border-l border-hairline pl-2.5 text-[11px]',
|
||||
copied ? 'text-success' : 'text-secondary',
|
||||
)}
|
||||
>
|
||||
{copied ? '✓ copied' : '⧉ copy'}
|
||||
</span>
|
||||
<span className="sr-only" aria-live="polite">
|
||||
{copied ? 'Copied to clipboard' : ''}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCopy } from '@/hooks/use-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The copy control in a terminal header. Styled against the terminal
|
||||
* surface tokens, not the page ones, so it reads as part of the window. */
|
||||
export const CopyButton = ({ text, className }: CopyButtonProps) => {
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copy(text)
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2 py-[3px] font-mono text-[11px] transition-colors',
|
||||
copied
|
||||
? 'border-[var(--tm-ok)] text-[var(--tm-ok)]'
|
||||
: 'border-[var(--tm-bd)] text-[var(--tm-dim)] hover:border-[var(--tm-bd3)] hover:text-[var(--tm-fg)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true">{copied ? '✓' : '⧉'}</span>
|
||||
{copied ? 'copied' : 'copy'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCopy } from '@/hooks/use-copy'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FeatureCommandProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
const FeatureCommand = ({ text }: FeatureCommandProps) => {
|
||||
const { copied, copy } = useCopy()
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title="Copy command"
|
||||
onClick={() => {
|
||||
copy(text)
|
||||
}}
|
||||
className={cn(
|
||||
'mt-auto flex w-full items-center gap-2 rounded-md border bg-[var(--cmd-bg)] px-2.5 py-2 text-left font-mono text-[11.5px] leading-[1.5] text-[var(--tm-fg)]',
|
||||
copied
|
||||
? 'border-[var(--tm-ok)]'
|
||||
: 'border-[var(--tm-bd)] hover:border-[var(--tm-bd3)]',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden="true" className="shrink-0 text-[var(--tm-pr)]">
|
||||
$
|
||||
</span>
|
||||
<code className="grow font-mono break-words whitespace-pre-wrap">{text}</code>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('shrink-0', copied ? 'text-[var(--tm-ok)]' : 'text-[var(--tm-dim)]')}
|
||||
>
|
||||
{copied ? '✓' : '⧉'}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface FeatureCardProps {
|
||||
/** A single glyph. Decorative — the title carries the meaning. */
|
||||
glyph?: string
|
||||
/** A CSS colour for the glyph, from the semantic token set. */
|
||||
color?: string
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
cmd?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const FeatureCard = ({
|
||||
glyph,
|
||||
color,
|
||||
title,
|
||||
children,
|
||||
cmd,
|
||||
className,
|
||||
}: FeatureCardProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2.5 rounded-md border border-hairline bg-panel p-[18px] transition-[border-color,background] duration-[120ms] hover:border-line hover:bg-raised sm:p-[22px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{glyph !== undefined && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex size-[34px] shrink-0 items-center justify-center rounded-md border border-hairline text-xl leading-none"
|
||||
style={color !== undefined ? { color } : undefined}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[15px] font-medium text-primary">{title}</div>
|
||||
</div>
|
||||
<div className="text-[13px] leading-[21px] text-pretty text-secondary [&_code]:font-mono [&_code]:text-primary">
|
||||
{children}
|
||||
</div>
|
||||
{cmd !== undefined && <FeatureCommand text={cmd} />}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CodeProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Anything typeable: flags, commands, env vars, crate names, paths. */
|
||||
export const Code = ({ children, className }: CodeProps) => (
|
||||
<code
|
||||
className={cn(
|
||||
'whitespace-nowrap rounded-md border border-hairline bg-raised px-1.5 py-px font-mono text-[0.92em] text-primary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WrapProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The site's single content column. One max-width, one gutter, everywhere. */
|
||||
export const Wrap = ({ children, className }: WrapProps) => (
|
||||
<div className={cn('mx-auto w-full max-w-[1140px] px-[18px] sm:px-7', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface SectionProps {
|
||||
children: React.ReactNode
|
||||
/** Drops the top hairline — for the first section under the nav. */
|
||||
flush?: boolean
|
||||
/** Panel background plus rules top and bottom. */
|
||||
band?: boolean
|
||||
id?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Section = ({ children, flush, band, id, className }: SectionProps) => (
|
||||
<section
|
||||
id={id}
|
||||
className={cn(
|
||||
'py-6 pb-10 sm:pt-[30px] sm:pb-13',
|
||||
band
|
||||
? 'border-y border-hairline bg-panel'
|
||||
: !flush && 'border-t border-hairline',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Wrap>{children}</Wrap>
|
||||
</section>
|
||||
)
|
||||
|
||||
interface EyebrowProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const Eyebrow = ({ children, className }: EyebrowProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
'text-label uppercase tracking-[var(--tracking-label)] text-accent',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
interface SectionHeadProps {
|
||||
eyebrow?: string
|
||||
title: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SectionHead = ({
|
||||
eyebrow,
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
}: SectionHeadProps) => (
|
||||
<div className={cn('mb-11 flex max-w-[660px] flex-col gap-3.5', className)}>
|
||||
{eyebrow !== undefined && <Eyebrow>{eyebrow}</Eyebrow>}
|
||||
<h2 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
{title}
|
||||
</h2>
|
||||
{children !== undefined && (
|
||||
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface PageHeadProps {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/** The h1 block every subpage opens with. */
|
||||
export const PageHead = ({ eyebrow, title, children }: PageHeadProps) => (
|
||||
<Section flush className="pt-10">
|
||||
<div className="flex max-w-[660px] flex-col gap-3.5">
|
||||
<Eyebrow>{eyebrow}</Eyebrow>
|
||||
<h1 className="text-[26px] leading-[34px] font-medium tracking-[-0.005em] text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-base leading-[26px] text-pretty text-secondary">{children}</p>
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
|
||||
interface ColsProps {
|
||||
children: React.ReactNode
|
||||
/** Aligns columns to their tops instead of stretching. */
|
||||
start?: boolean
|
||||
center?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** The site's only multi-column layout: two up on desktop, stacked below. */
|
||||
export const Cols = ({ children, start, center, className }: ColsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-7 md:grid-cols-2 md:gap-10',
|
||||
start === true && 'items-start',
|
||||
center === true && 'items-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ProseProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Body copy: secondary colour, generous leading, `+p` spacing. */
|
||||
export const Prose = ({ children, className }: ProseProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'[&>p+p]:mt-4 [&>p]:leading-[26px] [&>p]:text-pretty [&>p]:text-secondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
export type OsName = 'macOS' | 'Linux' | 'Windows'
|
||||
|
||||
const PATHS: Record<Exclude<OsName, 'Windows'>, string> = {
|
||||
macOS:
|
||||
'M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701',
|
||||
Linux:
|
||||
'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.132 1.884 1.071.771-.06 1.592-.536 2.257-1.306.631-.765 1.683-1.084 2.378-1.503.348-.199.629-.469.649-.853.023-.4-.2-.811-.714-1.376v-.097l-.003-.003c-.17-.2-.25-.535-.338-.926-.085-.401-.182-.786-.492-1.046h-.003c-.059-.054-.123-.067-.188-.135a.357.357 0 00-.19-.064c.431-1.278.264-2.55-.173-3.694-.533-1.41-1.465-2.638-2.175-3.483-.796-1.005-1.576-1.957-1.56-3.368.026-2.152.236-6.133-3.544-6.139z',
|
||||
}
|
||||
|
||||
const WINDOWS_PATH =
|
||||
'M2 3.6l8.5-1.2v9.1H2V3.6zm10 -1.35L22.5 1v10.5H12V2.25zM2 12.5h8.5v9.1L2 20.4v-7.9zm10 0h10.5V23L12 21.75V12.5z'
|
||||
|
||||
/** Decorative — the adjacent label already names the platform. */
|
||||
export const OsIcon = ({ name }: { name: OsName }) => (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className="block size-[22px] shrink-0 text-primary"
|
||||
>
|
||||
<path fill="currentColor" d={name === 'Windows' ? WINDOWS_PATH : PATHS[name]} />
|
||||
</svg>
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface PanelProps {
|
||||
/** Pane number, rendered in signal blue like the TUI's `[2]` tags. */
|
||||
num?: number | undefined
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The fieldset/legend motif lifted from the TUI: a bordered box whose title
|
||||
* breaks the top border. `bg-base` on the legend is what punches the gap, so
|
||||
* it has to match whatever surface the panel sits on.
|
||||
*/
|
||||
export const Panel = ({ num, title, children, className }: PanelProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-md border border-hairline bg-panel px-[18px] py-[22px] sm:px-6 sm:py-[26px]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="absolute -top-2.5 left-4 bg-base px-2 text-label uppercase tracking-[var(--tracking-label)] text-secondary">
|
||||
{/* --color-info, not the raw --color-signal: this panel renders on the
|
||||
page, outside .tui-content, where the raw palette never re-cuts. */}
|
||||
{num !== undefined && <span className="text-info">[{num}] </span>}
|
||||
{title}
|
||||
</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface CalloutProps {
|
||||
title?: string
|
||||
tone?: 'accent' | 'warn'
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** An aside with a coloured left rule. `warn` carries the tape amber. */
|
||||
export const Callout = ({ title, tone = 'accent', children, className }: CalloutProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border border-hairline border-l-[3px] bg-panel px-[18px] py-5 sm:px-[22px]',
|
||||
tone === 'warn' ? 'border-l-warning' : 'border-l-accent',
|
||||
'[&>p]:leading-[25px] [&>p]:text-secondary',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title !== undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 text-label uppercase tracking-[var(--tracking-label)]',
|
||||
tone === 'warn' ? 'text-warning' : 'text-accent',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** `cmd` gets a prompt glyph; `cm` is a comment; `code` and `out` are plain. */
|
||||
export type TermLineKind = 'cmd' | 'cm' | 'code' | 'out'
|
||||
|
||||
export interface TermLine {
|
||||
t: TermLineKind
|
||||
text: string
|
||||
}
|
||||
|
||||
const LINE_TONE: Record<TermLineKind, string> = {
|
||||
cmd: '',
|
||||
cm: 'text-[var(--tm-cm)]',
|
||||
code: '',
|
||||
out: '',
|
||||
}
|
||||
|
||||
interface TerminalProps {
|
||||
label?: string
|
||||
lines: readonly TermLine[]
|
||||
/** Overrides what the copy button yields. Defaults to the commands only. */
|
||||
copy?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A terminal window. Paints from the `--tm-*` token set rather than the page
|
||||
* tokens, so it stays a dark terminal in light mode instead of dissolving
|
||||
* into the page.
|
||||
*/
|
||||
export const Terminal = ({ label, lines, copy, className }: TerminalProps) => {
|
||||
const copyText =
|
||||
copy ??
|
||||
lines
|
||||
.filter((line) => line.t === 'cmd')
|
||||
.map((line) => line.text)
|
||||
.join('\n')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-md border border-[var(--tm-bd)] bg-[var(--tm-bg)] font-mono tabular-nums',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--tm-bd2)] bg-[var(--tm-hd)] px-3 py-2">
|
||||
<span className="text-label uppercase tracking-[var(--tracking-label)] text-[var(--tm-dim)]">
|
||||
{label ?? 'terminal'}
|
||||
</span>
|
||||
<CopyButton text={copyText} />
|
||||
</div>
|
||||
<div className="overflow-x-auto px-4 py-4 text-[12.5px] leading-[1.75] text-[var(--tm-fg)] sm:text-[13.5px]">
|
||||
{lines.map((line, i) => (
|
||||
<span
|
||||
// Lines are static content, never reordered — index is stable here.
|
||||
key={i}
|
||||
className={cn('block whitespace-pre', LINE_TONE[line.t])}
|
||||
>
|
||||
{line.t === 'cmd' && (
|
||||
<>
|
||||
<span className="text-[var(--tm-pr)]">$</span>{' '}
|
||||
</>
|
||||
)}
|
||||
{line.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { StaticRouter } from 'react-router'
|
||||
|
||||
import { App } from '@/App'
|
||||
import { ThemeProvider } from '@/theme/theme-provider'
|
||||
|
||||
/**
|
||||
* Renders one route to a static HTML string.
|
||||
*
|
||||
* `location` is the app-relative path (no base prefix) — the client mounts
|
||||
* with `basename`, so the two see the same paths and hydration matches.
|
||||
*/
|
||||
export const render = (location: string): string =>
|
||||
renderToString(
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
<StaticRouter location={location}>
|
||||
<App />
|
||||
</StaticRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
const RESET_MS = 1400
|
||||
|
||||
/**
|
||||
* Copy-to-clipboard with a self-clearing "copied" flag.
|
||||
*
|
||||
* no execCommand fallback. `navigator.clipboard` is available in
|
||||
* every secure context, and the site only ever ships over HTTPS (GitHub
|
||||
* Pages) or localhost. If a copy does fail, the flag stays off and the text
|
||||
* is still selectable by hand.
|
||||
*/
|
||||
export const useCopy = (): { copied: boolean; copy: (text: string) => void } => {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timer.current !== null) clearTimeout(timer.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const copy = useCallback((text: string) => {
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
setCopied(true)
|
||||
if (timer.current !== null) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, RESET_MS)
|
||||
},
|
||||
() => {
|
||||
// Permission denied or no clipboard: leave the button untouched.
|
||||
},
|
||||
)
|
||||
}, [])
|
||||
|
||||
return { copied, copy }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router'
|
||||
|
||||
import { metaFor } from '@/lib/routes'
|
||||
|
||||
/**
|
||||
* Keeps the title and meta description in step with the current route.
|
||||
*
|
||||
* Each route is also prerendered with its tags already in the HTML, so
|
||||
* crawlers never depend on this running; it only covers client-side
|
||||
* navigation after the first paint.
|
||||
*/
|
||||
export const usePageMeta = (): void => {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const meta = metaFor(pathname)
|
||||
document.title = meta.title
|
||||
document
|
||||
.querySelector('meta[name="description"]')
|
||||
?.setAttribute('content', meta.description)
|
||||
}, [pathname])
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/*
|
||||
* soulseek-rs — design tokens.
|
||||
*
|
||||
* Ported verbatim from the design system's tokens/{colors,typography,spacing}.css.
|
||||
* The same values back the TUI in soulseek-rs/src/ui/styles.rs, so the terminal
|
||||
* and the site stay one product. Dark is the native theme; light re-cuts the
|
||||
* semantic hues ~10% darker because phosphor and tape read on vinyl but wash
|
||||
* out on manila.
|
||||
*
|
||||
* Theme switching is driven by [data-theme] on <html>, matching the design's
|
||||
* own selector, rather than Tailwind's default `.dark` class.
|
||||
*/
|
||||
@custom-variant light (&:where([data-theme='light'], [data-theme='light'] *));
|
||||
|
||||
@theme {
|
||||
/* ---- Raw palette (dark-native names) ---- */
|
||||
--color-sleeve: #14110f; /* warm off-black — cardboard shadow, never #000 */
|
||||
--color-vinyl: #1e1a17; /* panels / surfaces, one step up from base */
|
||||
--color-vinyl-2: #26211d; /* raised surface / hover on panels */
|
||||
--color-dust: #8a8178; /* muted warm grey — secondary text, borders */
|
||||
--color-paper: #e8e1d6; /* aged insert card — primary text, never #FFF */
|
||||
--color-paper-dim: #c9c1b4;
|
||||
|
||||
--color-oxide: #c1502e; /* iron-oxide accent, deeper than Rust's own #CE422B */
|
||||
--color-tape: #d8a657; /* cassette-shell amber — warnings, queued */
|
||||
--color-phosphor: #7fb685; /* desaturated LCD green — success, connected */
|
||||
--color-signal: #6e9dc9; /* softened windows-classic blue — links, info */
|
||||
--color-alarm: #b33a3a; /* error red, distinguishable from oxide */
|
||||
|
||||
--color-manila: #f2ede4;
|
||||
--color-manila-2: #e8e1d6;
|
||||
--color-manila-3: #ded5c7;
|
||||
|
||||
/* ---- Semantic aliases, wired to the runtime vars below ---- */
|
||||
--color-base: var(--bg-base);
|
||||
--color-panel: var(--bg-panel);
|
||||
--color-raised: var(--bg-raised);
|
||||
|
||||
--color-primary: var(--text-primary);
|
||||
--color-secondary: var(--text-secondary);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-inverse: var(--text-inverse);
|
||||
|
||||
--color-hairline: var(--border-hairline);
|
||||
--color-line: var(--border-default);
|
||||
--color-line-strong: var(--border-strong);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-hover: var(--accent-hover);
|
||||
--color-accent-active: var(--accent-active);
|
||||
|
||||
--color-success: var(--status-success);
|
||||
--color-warning: var(--status-warning);
|
||||
--color-info: var(--status-info);
|
||||
--color-error: var(--status-error);
|
||||
|
||||
--color-link: var(--link);
|
||||
--color-link-hover: var(--link-hover);
|
||||
|
||||
/* ---- Typography: monospace, non-negotiable. It's a terminal application. ---- */
|
||||
--font-mono:
|
||||
'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
/* the "earned wink" — the era's forum font. One surface only. */
|
||||
--font-forum: Verdana, Tahoma, sans-serif;
|
||||
|
||||
/* Scale, from the brandbook specimen (48/56, 32/40, 20/28, 14/22, 12/18) */
|
||||
--text-display: 48px;
|
||||
--text-display--line-height: 56px;
|
||||
--text-title: 32px;
|
||||
--text-title--line-height: 40px;
|
||||
--text-heading: 20px;
|
||||
--text-heading--line-height: 28px;
|
||||
--text-body: 14px;
|
||||
--text-body--line-height: 22px;
|
||||
--text-caption: 12px;
|
||||
--text-caption--line-height: 18px;
|
||||
--text-label: 11px;
|
||||
--text-label--line-height: 14px;
|
||||
|
||||
--tracking-label: 0.06em;
|
||||
|
||||
/* Radii — favicon 16, panel card 8, cursor 3 */
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 16px;
|
||||
|
||||
/* Restrained. A desk, not a dashboard. */
|
||||
--shadow-panel:
|
||||
0 1px 0 rgb(0 0 0 / 0.35), 0 8px 24px rgb(0 0 0 / 0.35);
|
||||
|
||||
--animate-cursor-blink: ss-cursor-blink 1.06s steps(1) infinite;
|
||||
|
||||
@keyframes ss-cursor-blink {
|
||||
0%,
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
50.01%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- DARK is the default theme ---- */
|
||||
:root {
|
||||
--bg-base: var(--color-sleeve);
|
||||
--bg-panel: var(--color-vinyl);
|
||||
--bg-raised: var(--color-vinyl-2);
|
||||
--bg-selection: rgb(110 157 201 / 0.22);
|
||||
|
||||
/* Brightened from the raw palette for web body contrast — still warm. */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #e4dfd8;
|
||||
--text-muted: #bdb4a9;
|
||||
--text-inverse: var(--color-sleeve);
|
||||
|
||||
--border-hairline: rgb(138 129 120 / 0.35);
|
||||
--border-default: rgb(138 129 120 / 0.7);
|
||||
--border-strong: var(--color-dust);
|
||||
|
||||
--accent: var(--color-oxide);
|
||||
--accent-hover: #d05f3c;
|
||||
--accent-active: #a8431f;
|
||||
|
||||
--status-warning: var(--color-tape);
|
||||
--status-success: var(--color-phosphor);
|
||||
--status-info: var(--color-signal);
|
||||
--status-error: var(--color-alarm);
|
||||
|
||||
--link: var(--color-signal);
|
||||
--link-hover: #8bb4dc;
|
||||
|
||||
/* Terminal surface — a real terminal, darker than the page around it. */
|
||||
--tm-bg: #1e1a17;
|
||||
--tm-hd: #181513;
|
||||
--tm-fg: #ffffff;
|
||||
--tm-dim: #bdb4a9;
|
||||
--tm-bd: rgb(138 129 120 / 0.32);
|
||||
--tm-bd2: rgb(138 129 120 / 0.22);
|
||||
--tm-bd3: rgb(138 129 120 / 0.6);
|
||||
--tm-pr: #c1502e;
|
||||
--tm-cm: #aba298;
|
||||
--tm-ok: #7fb685;
|
||||
--tm-fl: #d8a657;
|
||||
--tm-sg: #6e9dc9;
|
||||
--cmd-bg: #0e0c0a;
|
||||
--tui-track: #2a2622;
|
||||
--tui-bezel: #000000;
|
||||
|
||||
/* tmux's own status bar. Emulator chrome, so it does not follow the theme. */
|
||||
--tmux-bg: #0e0c0a;
|
||||
--tmux-hl: #b9a6e8;
|
||||
--tmux-hl-fg: #1a1526;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* ---- LIGHT: base becomes manila, sleeve becomes ink, accents darken ~10% ---- */
|
||||
[data-theme='light'] {
|
||||
--bg-base: var(--color-manila);
|
||||
--bg-panel: var(--color-manila-2);
|
||||
--bg-raised: var(--color-manila-3);
|
||||
--bg-selection: rgb(76 127 174 / 0.2);
|
||||
|
||||
--text-primary: var(--color-sleeve);
|
||||
--text-secondary: #574e45;
|
||||
--text-muted: #6f655b;
|
||||
--text-inverse: var(--color-paper);
|
||||
|
||||
--border-hairline: rgb(20 17 15 / 0.15);
|
||||
--border-default: rgb(20 17 15 / 0.28);
|
||||
--border-strong: #6b6259;
|
||||
|
||||
--accent: #ae4728;
|
||||
--accent-hover: #c1502e;
|
||||
--accent-active: #953b1f;
|
||||
|
||||
--status-warning: #c08e3d;
|
||||
--status-success: #5e9a66;
|
||||
--status-info: #4c7fae;
|
||||
--status-error: #9a2f2f;
|
||||
|
||||
--link: #4c7fae;
|
||||
--link-hover: #3b6a95;
|
||||
|
||||
--tm-bg: #e9e2d7;
|
||||
--tm-hd: #dcd3c4;
|
||||
--tm-fg: #14110f;
|
||||
--tm-dim: #574e45;
|
||||
--tm-bd: rgb(20 17 15 / 0.2);
|
||||
--tm-bd2: rgb(20 17 15 / 0.13);
|
||||
--tm-bd3: rgb(20 17 15 / 0.42);
|
||||
--tm-pr: #9e3f22;
|
||||
--tm-cm: #6b6259;
|
||||
--tm-ok: #3f7147;
|
||||
--tm-fl: #8f6420;
|
||||
--tm-sg: #35648f;
|
||||
--cmd-bg: #dcd3c4;
|
||||
--tui-track: #cfc5b5;
|
||||
--tui-bezel: rgb(20 17 15 / 0.28);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
/*
|
||||
* Only the cut that is actually shown gets fetched — a hidden <img> would
|
||||
* still be downloaded. url() is relative to this stylesheet, which Vite
|
||||
* rebases against `base`, so the deploy path stays in vite.config.ts.
|
||||
*/
|
||||
.logo-mark {
|
||||
background-image: url('/logo-ink.svg');
|
||||
}
|
||||
|
||||
[data-theme='light'] .logo-mark {
|
||||
background-image: url('/logo-light.svg');
|
||||
}
|
||||
|
||||
/*
|
||||
* The TUI panes paint from the raw palette (paper, dust, oxide…) because that
|
||||
* is what the Rust client does. Those names do not flip with the theme, so on
|
||||
* manila they would leave paper-on-paper. Light mode re-cuts the same names
|
||||
* darker, scoped to the terminal's content.
|
||||
*
|
||||
* Deliberately not applied to the tmux bar above it: that is the emulator's
|
||||
* own chrome, it keeps its near-black background in both themes, and needs
|
||||
* the bright phosphor to stay legible against it.
|
||||
*/
|
||||
[data-theme='light'] .tui-content {
|
||||
--color-paper: #14110f;
|
||||
--color-paper-dim: #3a332c;
|
||||
--color-dust: #645b52;
|
||||
--color-oxide: #9e3f22;
|
||||
--color-tape: #8f6420;
|
||||
--color-phosphor: #3f7147;
|
||||
--color-signal: #35648f;
|
||||
--color-alarm: #8f2626;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* Anchored headings clear the sticky nav instead of hiding under it. */
|
||||
scroll-padding-top: 5rem;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-base);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-body);
|
||||
line-height: var(--text-body--line-height);
|
||||
/* Tabular figures EVERYWHERE — speeds, sizes, queue positions, ETAs. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--bg-selection);
|
||||
}
|
||||
|
||||
/* The design system's own focus treatment, applied once globally. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Every off-site destination, in one place. */
|
||||
export const LINKS = {
|
||||
gh: 'https://github.com/michel/soulseek-rs',
|
||||
issues: 'https://github.com/michel/soulseek-rs/issues',
|
||||
releases: 'https://github.com/michel/soulseek-rs/releases',
|
||||
changelog: 'https://github.com/michel/soulseek-rs/blob/master/CHANGELOG.md',
|
||||
license: 'https://github.com/michel/soulseek-rs/blob/master/LICENSE',
|
||||
cratesClient: 'https://crates.io/crates/soulseek-rs',
|
||||
cratesLib: 'https://crates.io/crates/soulseek-rs-lib',
|
||||
docsrs: 'https://docs.rs/soulseek-rs-lib',
|
||||
rustup: 'https://rustup.rs',
|
||||
soulfind: 'https://github.com/soulfind-dev/soulfind',
|
||||
nicotine: 'https://nicotine-plus.org',
|
||||
slskd: 'https://github.com/slskd/slskd',
|
||||
slsknet: 'https://www.slsknet.org',
|
||||
} as const
|
||||
|
||||
/** Injected from the workspace Cargo.toml at build time — see vite.config.ts. */
|
||||
export const VERSION = __APP_VERSION__
|
||||
|
||||
/** Where the site is served from. Used for canonical and og:url. */
|
||||
export const SITE_URL = 'https://michel.github.io/soulseek-rs/'
|
||||
@@ -0,0 +1,61 @@
|
||||
export interface RouteMeta {
|
||||
/** Path without the base prefix, always leading-slashed. */
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The single source of truth for what pages exist.
|
||||
*
|
||||
* Read three times: React Router matches against it, the layout applies the
|
||||
* meta on navigation, and scripts/prerender.ts walks it to decide which HTML
|
||||
* files to write. Adding a page means adding one entry here plus its route.
|
||||
*/
|
||||
/*
|
||||
* `as const satisfies` rather than a plain annotation: the annotation widened
|
||||
* `path` to `string`, which is what let App.tsx's hand-written route list
|
||||
* drift from this one without failing the build. Literal paths make the
|
||||
* mapping in App.tsx exhaustive-checkable.
|
||||
*/
|
||||
export const ROUTES = [
|
||||
{
|
||||
path: '/',
|
||||
title: 'soulseek-rs, a Soulseek client for your terminal',
|
||||
description:
|
||||
'soulseek-rs is a keyboard-driven Soulseek client and a Rust protocol library. Search, share, browse, and chat from your terminal.',
|
||||
},
|
||||
{
|
||||
path: '/install',
|
||||
title: 'soulseek-rs, install',
|
||||
description:
|
||||
'Install the soulseek-rs client with cargo, build it from source, or depend on the protocol library and write your own client.',
|
||||
},
|
||||
{
|
||||
path: '/docs',
|
||||
title: 'soulseek-rs, docs',
|
||||
description:
|
||||
'Quick start for soulseek-rs: install, run, drive it from the keyboard, and use the shell subcommands.',
|
||||
},
|
||||
{
|
||||
path: '/community',
|
||||
title: 'soulseek-rs, community',
|
||||
description:
|
||||
'How to help with soulseek-rs, and where to go when another Soulseek client fits you better. MIT licensed. No telemetry.',
|
||||
},
|
||||
] as const satisfies readonly RouteMeta[]
|
||||
|
||||
/** Every path the site serves, as literals. */
|
||||
export type RoutePath = (typeof ROUTES)[number]['path']
|
||||
|
||||
export const NOT_FOUND_META: RouteMeta = {
|
||||
path: '/404',
|
||||
title: 'soulseek-rs, not found',
|
||||
description: 'That page does not exist.',
|
||||
}
|
||||
|
||||
export const metaFor = (pathname: string): RouteMeta => {
|
||||
const trimmed =
|
||||
pathname.length > 1 && pathname.endsWith('/') ? pathname.slice(0, -1) : pathname
|
||||
return ROUTES.find((route) => route.path === trimmed) ?? NOT_FOUND_META
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs))
|
||||
@@ -0,0 +1,31 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot, hydrateRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
|
||||
import { App } from '@/App'
|
||||
import { ThemeProvider } from '@/theme/theme-provider'
|
||||
|
||||
import './index.css'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (container === null) throw new Error('Root element #root is missing')
|
||||
|
||||
const tree = (
|
||||
<StrictMode>
|
||||
<ThemeProvider>
|
||||
{/* Must match `base` in vite.config.ts — the site is served from a
|
||||
subpath, so the router has to strip it before matching. */}
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
// Every route ships prerendered markup, so the normal path is to hydrate it.
|
||||
// createRoot is the fallback for a container that somehow arrived empty.
|
||||
if (container.hasChildNodes()) {
|
||||
hydrateRoot(container, tree)
|
||||
} else {
|
||||
createRoot(container).render(tree)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FeatureCard } from '@/components/ui/feature-card'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import { Cols, Eyebrow, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
|
||||
import { Callout } from '@/components/ui/panel'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
interface Alternative {
|
||||
question: string
|
||||
answer: React.ReactNode
|
||||
links: readonly { label: string; href: string }[]
|
||||
}
|
||||
|
||||
const ALTERNATIVES: readonly Alternative[] = [
|
||||
{
|
||||
question: 'Want a window and a mouse?',
|
||||
answer: <>SoulseekQt or Nicotine+. Both are good.</>,
|
||||
links: [
|
||||
{ label: 'SoulseekQt', href: LINKS.slsknet },
|
||||
{ label: 'Nicotine+', href: LINKS.nicotine },
|
||||
],
|
||||
},
|
||||
{
|
||||
question: 'Want feature completeness?',
|
||||
answer: (
|
||||
<>
|
||||
Nicotine+ has more: wishlists, user lists, transfer rules, plugins, translations.
|
||||
It’s mature and well run.
|
||||
</>
|
||||
),
|
||||
links: [{ label: 'Nicotine+', href: LINKS.nicotine }],
|
||||
},
|
||||
{
|
||||
question: 'Want to point automation at the network?',
|
||||
answer: <>slskd does that today. Daemon mode here is unbuilt.</>,
|
||||
links: [{ label: 'slskd', href: LINKS.slskd }],
|
||||
},
|
||||
]
|
||||
|
||||
const CITIZENSHIP = [
|
||||
{
|
||||
glyph: '↑',
|
||||
color: 'var(--status-success)',
|
||||
title: 'Share, and share real files',
|
||||
body: (
|
||||
<>
|
||||
Point <code>--shared-dir</code> at the collection you actually listen to. Full
|
||||
albums, honest bitrates, nothing transcoded up from a lossy source and relabelled.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '≡',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Organize so others can find it',
|
||||
body: (
|
||||
<>
|
||||
Artist and album folders, tagged files, readable names. Someone else’s search
|
||||
only reaches you if your filenames say what the music is.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '∞',
|
||||
color: 'var(--accent)',
|
||||
title: 'Stay online',
|
||||
body: (
|
||||
<>
|
||||
Leave it running in a tmux session on the box that is already on. A share that
|
||||
appears for an hour a week is close to no share at all.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '↔',
|
||||
color: 'var(--status-warning)',
|
||||
title: 'Let people reach you',
|
||||
body: (
|
||||
<>
|
||||
Forward your listen port or let <code>portmap</code> try UPnP, and leave a free
|
||||
upload slot. Two firewalled peers cannot trade with each other.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '@',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Answer people',
|
||||
body: (
|
||||
<>
|
||||
Rooms and private messages are part of the client for a reason. Someone asking
|
||||
where the rest of a discography went is worth a reply.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '⋯',
|
||||
color: 'var(--text-secondary)',
|
||||
title: 'Wait your turn',
|
||||
body: (
|
||||
<>
|
||||
Queues are one person’s upload bandwidth being divided up. Queue it, leave it,
|
||||
come back later. Do not requeue the same file to jump the line.
|
||||
</>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export const Community = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
eyebrow="community"
|
||||
title="Help out, or go where you're better served."
|
||||
>
|
||||
It’s mostly one person who’s been on Soulseek since the early 2000s.
|
||||
Issues and pull requests are welcome, and if another client fits you better, use it.
|
||||
</PageHead>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="contributing" title="How to help.">
|
||||
File an issue, open a pull request, or just say what’s missing. No
|
||||
contributor agreement, no template gymnastics.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Prose>
|
||||
<p>
|
||||
The project is a Cargo workspace: <Code>soulseek-rs-lib</Code> (the protocol)
|
||||
and <Code>soulseek-rs</Code> (the client). The library stays lean on
|
||||
dependencies; the client takes them freely.
|
||||
</p>
|
||||
<p>
|
||||
Format and Clippy run clean on every push, and unit tests plus an end-to-end
|
||||
suite against{' '}
|
||||
<a
|
||||
href={LINKS.soulfind}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
soulfind
|
||||
</a>{' '}
|
||||
run in CI, so a green checkmark means it genuinely built and talked to a
|
||||
server.
|
||||
</p>
|
||||
<p>
|
||||
That server is{' '}
|
||||
<a
|
||||
href={LINKS.soulfind}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
soulfind-dev/soulfind
|
||||
</a>
|
||||
, an open-source Soulseek server. Run it locally and you can develop and test
|
||||
against a real protocol implementation without touching the public network.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.issues} variant="accent">
|
||||
Open an issue ↗
|
||||
</Button>
|
||||
<Button href={LINKS.gh}>GitHub ↗</Button>
|
||||
</div>
|
||||
</Prose>
|
||||
<Terminal
|
||||
label="develop"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'RUST_LOG=trace cargo run' },
|
||||
{ t: 'cm', text: '# run with debug + trace output' },
|
||||
{ t: 'cmd', text: 'cargo test' },
|
||||
{ t: 'cmd', text: 'cargo clippy' },
|
||||
{ t: 'cmd', text: 'cargo fmt' },
|
||||
]}
|
||||
/>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="being a good member"
|
||||
title="The network is other people's hard drives."
|
||||
>
|
||||
Soulseek is a gem for music enthusiasts. Decades of records, live sets, and
|
||||
pressings that never made it anywhere else, kept online by people who care about
|
||||
them. It works because someone left their collection up for you. None of this is
|
||||
enforced. It is just how the network stays worth using.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{CITIZENSHIP.map((item) => (
|
||||
<FeatureCard
|
||||
key={item.title}
|
||||
glyph={item.glyph}
|
||||
color={item.color}
|
||||
title={item.title}
|
||||
>
|
||||
{item.body}
|
||||
</FeatureCard>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="what this is not"
|
||||
title="Three good clients to point you elsewhere."
|
||||
>
|
||||
soulseek-rs has no GUI and no daemon mode yet. If one of these fits you better,
|
||||
use it. Recommending them is the strongest trust signal there is.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-3.5">
|
||||
{ALTERNATIVES.map((alt) => (
|
||||
<div
|
||||
key={alt.question}
|
||||
className="grid grid-cols-1 items-center gap-4 rounded-md border border-hairline bg-panel p-[18px] sm:grid-cols-[1fr_auto] sm:gap-x-6 sm:p-[22px] lg:grid-cols-1"
|
||||
>
|
||||
<div>
|
||||
<div className="mb-1.5 text-[15px] font-medium text-primary">
|
||||
{alt.question}
|
||||
</div>
|
||||
<div className="text-[13.5px] leading-[22px] text-secondary">
|
||||
{alt.answer}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
{alt.links.map((link) => (
|
||||
<Button key={link.label} href={link.href}>
|
||||
{link.label} ↗
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Cols center className="gap-8">
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<Eyebrow>license & promises</Eyebrow>
|
||||
<h2 className="text-[26px] leading-[34px] font-medium sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
MIT, and no strings.
|
||||
</h2>
|
||||
<p className="text-secondary">
|
||||
Free to use, copy, modify, and distribute. Copyright © 2026 Michel de Graaf.
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.license} variant="accent">
|
||||
Read the license ↗
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Callout title="two things that stay true">
|
||||
<p className="mb-1.5 !text-success">No telemetry. Ever.</p>
|
||||
<p className="font-forum text-xs !text-muted">
|
||||
Not affiliated with or endorsed by the Soulseek project.
|
||||
</p>
|
||||
</Callout>
|
||||
</Cols>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import {
|
||||
Cols,
|
||||
Eyebrow,
|
||||
PageHead,
|
||||
Prose,
|
||||
Section,
|
||||
SectionHead,
|
||||
} from '@/components/ui/layout'
|
||||
import { Callout, Panel } from '@/components/ui/panel'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
const GLOBAL_KEYS: readonly (readonly [string, string])[] = [
|
||||
['Space', 'select a result'],
|
||||
['Enter', 'download / send'],
|
||||
['b', "browse the owner's files"],
|
||||
['c', 'chat rooms'],
|
||||
['m', 'compose a private message'],
|
||||
['i', 'inbox (unread counter)'],
|
||||
['/', 'filter the current list'],
|
||||
['a / A', 'select all / none'],
|
||||
['1–3', 'focus a pane'],
|
||||
['q', 'quit'],
|
||||
]
|
||||
|
||||
const ROOM_KEYS: readonly (readonly [string, string])[] = [
|
||||
['Enter', 'join room / send message'],
|
||||
['Tab · ⇧Tab', 'switch between open rooms'],
|
||||
['x', 'leave the active room'],
|
||||
['l', 'back to the room list'],
|
||||
['↑ ↓', 'select a member'],
|
||||
['b', "browse the member's files"],
|
||||
['m', 'message the member'],
|
||||
]
|
||||
|
||||
const PANES: readonly { num?: number; title: string; body: string }[] = [
|
||||
{
|
||||
num: 1,
|
||||
title: 'Searches',
|
||||
body: 'Your queries and how many results each returned. Type / to search the network; each search keeps its own result set.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: 'Results',
|
||||
body: 'Files from the network: size, user, bitrate, speed, free slots. Space selects, Enter queues a download.',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: 'Downloads / Uploads',
|
||||
body: 'Live transfers in both directions with progress and speed: queued, active, complete, failed.',
|
||||
},
|
||||
{
|
||||
title: 'Info',
|
||||
body: 'Everything about the selected file: user, size, path, bitrate, length, queue position, free slots.',
|
||||
},
|
||||
]
|
||||
|
||||
const KeyRow = ({ combo, description }: { combo: string; description: string }) => (
|
||||
<div className="grid grid-cols-[120px_1fr] items-baseline gap-3 border-b border-hairline py-[7px]">
|
||||
<span>
|
||||
<span className="inline-block min-w-5 rounded-md border border-line border-b-2 px-[7px] text-center text-xs text-primary">
|
||||
{combo}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[13px] text-secondary">{description}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Docs = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
eyebrow="docs · quick start"
|
||||
title="Install it, run it, drive it from the keyboard."
|
||||
>
|
||||
This is the short version. The full reference (every flag, every message) lives in
|
||||
the README and on docs.rs.
|
||||
</PageHead>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="1 · first run" title="Two commands to a running client.">
|
||||
You need a Rust toolchain. After <Code>cargo install</Code>, the binary is on your
|
||||
PATH.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
||||
{ t: 'cmd', text: 'soulseek-rs' },
|
||||
{ t: 'cm', text: '# the TUI opens; log in or register on first run' },
|
||||
]}
|
||||
/>
|
||||
<Prose>
|
||||
<p>
|
||||
On first run soulseek-rs shows a login screen: sign in with an existing
|
||||
Soulseek account or register a new one, right in the TUI. Your password is
|
||||
stored in the OS keychain, not in plain text.
|
||||
</p>
|
||||
<p>
|
||||
Downloads land in a conventional folder by default, and your TUI state
|
||||
(searches, downloads, rooms) is saved and restored across restarts.
|
||||
</p>
|
||||
</Prose>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="2 · the layout" title="Four panes.">
|
||||
The whole client is these four boxes. Press <Code>1</Code>–<Code>3</Code> to focus
|
||||
one; the focused pane’s legend turns green.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{PANES.map((pane) => (
|
||||
<Panel key={pane.title} num={pane.num} title={pane.title}>
|
||||
<p className="text-[13px] leading-[22px] text-secondary">{pane.body}</p>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="3 · keys" title="It's all keyboard.">
|
||||
The bottom bar always shows the keys for where you are. The essentials:
|
||||
</SectionHead>
|
||||
<Cols>
|
||||
<div>
|
||||
<Eyebrow className="mb-4 block">anywhere</Eyebrow>
|
||||
<div className="flex flex-col">
|
||||
{GLOBAL_KEYS.map(([combo, description]) => (
|
||||
<KeyRow key={combo} combo={combo} description={description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Eyebrow className="mb-4 block">
|
||||
in the chat-rooms popup <span className="text-muted">(press c)</span>
|
||||
</Eyebrow>
|
||||
<div className="flex flex-col">
|
||||
{ROOM_KEYS.map(([combo, description]) => (
|
||||
<KeyRow key={combo} combo={combo} description={description} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead
|
||||
eyebrow="4 · from the shell"
|
||||
title="Every command also runs as a one-off."
|
||||
>
|
||||
Skip the TUI when you’re scripting or mid-task.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Terminal
|
||||
label="shell"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'soulseek-rs "public domain field recordings"' },
|
||||
{ t: 'cm', text: '# open the TUI straight onto a search' },
|
||||
{ t: 'cmd', text: 'soulseek-rs message <username> "hello there"' },
|
||||
{ t: 'cmd', text: 'soulseek-rs rooms' },
|
||||
{ t: 'cm', text: '# list public rooms, busiest first' },
|
||||
{ t: 'cmd', text: 'soulseek-rs chat <room> "hello room"' },
|
||||
{ t: 'cmd', text: 'soulseek-rs portmap' },
|
||||
]}
|
||||
/>
|
||||
<Prose>
|
||||
<p>
|
||||
The subcommands mirror the TUI: <Code>search</Code>, <Code>message</Code>,{' '}
|
||||
<Code>browse</Code>, <Code>rooms</Code>, <Code>chat</Code>, and{' '}
|
||||
<Code>portmap</Code>. Pass a room and a message to <Code>chat</Code> to say
|
||||
one thing and exit.
|
||||
</p>
|
||||
<p>
|
||||
<Code>portmap</Code> tests whether your router will let peers connect back,
|
||||
worth running once.{' '}
|
||||
<Link to="/install" className="text-link hover:text-link-hover">
|
||||
More on being reachable →
|
||||
</Link>
|
||||
</p>
|
||||
</Prose>
|
||||
</Cols>
|
||||
<div className="mt-6">
|
||||
<Callout tone="warn" title="the one limit worth knowing up front">
|
||||
<p>
|
||||
If both you and a peer are behind routers with no forwarded port, browsing
|
||||
that peer can’t work, that’s a fundamental Soulseek/peer-to-peer
|
||||
limitation, not a bug.
|
||||
</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Panel title="the rest">
|
||||
<Cols center className="gap-6">
|
||||
<Prose>
|
||||
<p>
|
||||
Deeper docs live where they stay current: the full README on GitHub, and the
|
||||
generated API reference for the library on docs.rs.
|
||||
</p>
|
||||
</Prose>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.gh} variant="accent">
|
||||
README ↗
|
||||
</Button>
|
||||
<Button href={LINKS.docsrs}>docs.rs ↗</Button>
|
||||
<Button href={LINKS.changelog}>Changelog ↗</Button>
|
||||
</div>
|
||||
</Cols>
|
||||
</Panel>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Link } from 'react-router'
|
||||
|
||||
import { Logo } from '@/components/brand/logo'
|
||||
import { HeroTerminal } from '@/components/tui/hero-terminal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CommandPill } from '@/components/ui/command-pill'
|
||||
import { FeatureCard } from '@/components/ui/feature-card'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import { Cols, Eyebrow, Prose, Section, SectionHead, Wrap } from '@/components/ui/layout'
|
||||
import { OsIcon, type OsName } from '@/components/ui/os-icon'
|
||||
import { Callout } from '@/components/ui/panel'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
const PLATFORMS: readonly { name: OsName; note: string; anchor: string }[] = [
|
||||
{ name: 'macOS', note: 'Apple Keychain', anchor: 'macos' },
|
||||
{ name: 'Linux', note: 'Secret Service', anchor: 'linux' },
|
||||
{ name: 'Windows', note: 'Credential Manager', anchor: 'windows' },
|
||||
]
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
glyph: '→',
|
||||
color: 'var(--accent)',
|
||||
title: 'Search & download',
|
||||
cmd: 'soulseek-rs "field recordings"',
|
||||
body: <>Search the network, pick results in the TUI, and queue downloads with pause, resume, and retry.</>,
|
||||
},
|
||||
{
|
||||
glyph: '↑',
|
||||
color: 'var(--status-success)',
|
||||
title: 'Sharing',
|
||||
cmd: 'soulseek-rs --shared-dir ~/Music',
|
||||
body: (
|
||||
<>
|
||||
Point <code>--shared-dir</code> at a directory and your files show up in searches;
|
||||
peers browse and download them.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '›',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Browse',
|
||||
cmd: 'soulseek-rs browse <user>',
|
||||
body: <>List any user’s shared files and download straight from the tree.</>,
|
||||
},
|
||||
{
|
||||
glyph: '#',
|
||||
color: 'var(--status-warning)',
|
||||
title: 'Chat rooms',
|
||||
cmd: 'soulseek-rs rooms',
|
||||
body: <>List, join, and talk in public rooms, several open at once as tabs.</>,
|
||||
},
|
||||
{
|
||||
glyph: '@',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Private messages',
|
||||
cmd: 'soulseek-rs message <user> "hi"',
|
||||
body: <>Send and receive PMs, with an inbox and an unread counter in the TUI.</>,
|
||||
},
|
||||
{
|
||||
glyph: '↔',
|
||||
color: 'var(--status-info)',
|
||||
title: 'Firewalled peers',
|
||||
cmd: 'soulseek-rs --listener-port 2234',
|
||||
body: (
|
||||
<>
|
||||
Downloads and browsing fall back to a server-brokered connection when a peer
|
||||
can’t be reached directly.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '↯',
|
||||
color: 'var(--status-warning)',
|
||||
title: 'Automatic port mapping',
|
||||
cmd: 'soulseek-rs portmap',
|
||||
body: (
|
||||
<>
|
||||
Opens your listen port via UPnP-IGD and NAT-PMP, with a <code>portmap</code>{' '}
|
||||
subcommand to test your router.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
glyph: '▮',
|
||||
color: 'var(--accent)',
|
||||
title: 'TUI and CLI',
|
||||
cmd: 'soulseek-rs',
|
||||
body: (
|
||||
<>
|
||||
A full terminal interface, plus scriptable subcommands: search, message, browse,
|
||||
rooms, chat, portmap.
|
||||
</>
|
||||
),
|
||||
},
|
||||
] as const
|
||||
|
||||
export const Home = () => {
|
||||
return (
|
||||
<>
|
||||
<section className="pt-5 pb-8 sm:pt-7 sm:pb-10">
|
||||
<Wrap>
|
||||
<div className="flex flex-col gap-[22px]">
|
||||
<Eyebrow>Soulseek client · terminal · Rust</Eyebrow>
|
||||
<div className="flex items-start gap-3.5 sm:gap-6 md:gap-11">
|
||||
<Logo size={172} className="mt-2 shrink-0 max-md:!h-28 max-sm:!h-16" />
|
||||
<h1 className="text-[29px] leading-[37px] font-bold tracking-[-0.01em] text-balance sm:text-[34px] sm:leading-[42px] md:text-display md:leading-[var(--text-display--line-height)]">
|
||||
A Soulseek client for people who live in the terminal.
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-lg leading-[29px] text-pretty text-secondary">
|
||||
Search the network, share your files, browse someone’s collection, join
|
||||
a room. It runs over ssh on the machine where your music already lives, with
|
||||
no window and no mouse.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-5 gap-y-3 sm:gap-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 max-sm:w-full sm:gap-3">
|
||||
<Button href="/install" variant="accent">
|
||||
Install
|
||||
</Button>
|
||||
<Button href="/docs">Read the docs</Button>
|
||||
<Button href={LINKS.gh}>GitHub ↗</Button>
|
||||
</div>
|
||||
<CommandPill text="cargo install soulseek-rs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3.5 overflow-hidden">
|
||||
<HeroTerminal />
|
||||
</div>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
<section className="border-t border-hairline py-6">
|
||||
<Wrap>
|
||||
<div className="flex flex-wrap items-center gap-3.5 sm:gap-8">
|
||||
<Eyebrow>Runs on</Eyebrow>
|
||||
<ul className="flex w-full flex-wrap sm:w-auto">
|
||||
{PLATFORMS.map((platform, i) => (
|
||||
<li
|
||||
key={platform.name}
|
||||
className={
|
||||
i === 0
|
||||
? 'flex w-full sm:w-auto sm:pr-6.5'
|
||||
: 'flex w-full border-hairline sm:w-auto sm:border-l sm:px-6.5'
|
||||
}
|
||||
>
|
||||
<Link
|
||||
to={`/install#${platform.anchor}`}
|
||||
className="group flex w-full items-center gap-2.5 border-t border-hairline py-2.5 first:border-t-0 sm:w-auto sm:border-t-0 sm:py-0"
|
||||
>
|
||||
<OsIcon name={platform.name} />
|
||||
<span className="flex flex-col leading-[1.35]">
|
||||
<b className="text-[13.5px] font-medium text-primary transition-colors group-hover:text-accent">
|
||||
{platform.name}
|
||||
</b>
|
||||
<span className="text-[11.5px] text-secondary">{platform.note}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
<Section id="about">
|
||||
<SectionHead
|
||||
eyebrow="what it is"
|
||||
title="A client for a network that runs on people."
|
||||
>
|
||||
No charts, no recommendations. You search, you find the person who has it, you
|
||||
download it from them.
|
||||
</SectionHead>
|
||||
<Cols>
|
||||
<Prose>
|
||||
<p>
|
||||
Soulseek is a peer-to-peer network from the early 2000s. People still use it
|
||||
to share niche music by hand: the album that exists in one person’s
|
||||
folder and nowhere else.
|
||||
</p>
|
||||
<p>
|
||||
soulseek-rs speaks that protocol. It’s a third-party client, not the
|
||||
official app, and not affiliated with the Soulseek project. It shares,
|
||||
browses, and does rooms and private messages, because a client that only
|
||||
downloads takes from the network without giving anything back.
|
||||
</p>
|
||||
</Prose>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Callout title="build your own">
|
||||
<p>
|
||||
Reach for <Code>soulseek-rs-lib</Code> to build a custom client or an agent
|
||||
on the protocol directly.{' '}
|
||||
<Link to="/install" className="text-link hover:text-link-hover">
|
||||
Use the library →
|
||||
</Link>
|
||||
</p>
|
||||
</Callout>
|
||||
<Callout title="share back">
|
||||
<p>
|
||||
Point <Code>--shared-dir</Code> at your library and leave it running in a
|
||||
tmux session on your share or headless box. The network only works because
|
||||
people share, and users who share back tend to get queue slots faster.
|
||||
</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section id="features">
|
||||
<SectionHead eyebrow="features" title="What it does.">
|
||||
Everything happens in the TUI, and every command also runs as a one-off from the
|
||||
shell, so the same client works for scripting, cron jobs, and agents driving it
|
||||
programmatically.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
glyph={feature.glyph}
|
||||
color={feature.color}
|
||||
title={feature.title}
|
||||
cmd={feature.cmd}
|
||||
>
|
||||
{feature.body}
|
||||
</FeatureCard>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<Cols center>
|
||||
<div className="flex flex-col gap-3.5">
|
||||
<Eyebrow>get started</Eyebrow>
|
||||
<h2 className="text-[26px] leading-[34px] font-medium text-balance sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
Install it, then press <span className="text-accent">/</span> to search.
|
||||
</h2>
|
||||
<p className="text-secondary">
|
||||
Needs a Rust toolchain. From there it’s one command, then the TUI opens.
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href="/docs" variant="accent">
|
||||
Read the docs
|
||||
</Button>
|
||||
<Button href="/install">Install options</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Terminal
|
||||
label="quick start"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
||||
{ t: 'cmd', text: 'soulseek-rs' },
|
||||
]}
|
||||
/>
|
||||
</Cols>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FeatureCard } from '@/components/ui/feature-card'
|
||||
import { Code } from '@/components/ui/inline-code'
|
||||
import { Cols, PageHead, Prose, Section, SectionHead } from '@/components/ui/layout'
|
||||
import { Callout } from '@/components/ui/panel'
|
||||
import { Terminal, type TermLine } from '@/components/ui/terminal'
|
||||
import { LINKS } from '@/lib/links'
|
||||
|
||||
const LIB_SRC = `use soulseek_rs::Client;
|
||||
use std::time::Duration;
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut client = Client::new("username", "password");
|
||||
client.connect();
|
||||
client.login()?;
|
||||
|
||||
let results = client.search("public domain field recordings", Duration::from_secs(10))?;
|
||||
if let Some(r) = results.iter().find(|r| !r.files.is_empty()) {
|
||||
let f = &r.files[0];
|
||||
client.download(f.name.clone(), f.username.clone(), f.size, "~/Downloads".to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}`
|
||||
|
||||
// LIB_SRC carries no comment lines, so every line is plain code.
|
||||
const LIB_LINES: TermLine[] = LIB_SRC.split('\n').map((text) => ({
|
||||
t: 'code' as const,
|
||||
text,
|
||||
}))
|
||||
|
||||
interface PlatformNote {
|
||||
id: string
|
||||
name: string
|
||||
credentialStore: string
|
||||
config: string
|
||||
state: string
|
||||
downloads: string
|
||||
note: string
|
||||
}
|
||||
|
||||
const PLATFORMS: readonly PlatformNote[] = [
|
||||
{
|
||||
id: 'macos',
|
||||
name: 'macOS',
|
||||
credentialStore: 'Apple Keychain',
|
||||
config: '~/.config/soulseek-rs/config.toml',
|
||||
state: '~/.local/share/soulseek-rs/state',
|
||||
downloads: '~/Downloads/Soulseek',
|
||||
note: 'macOS deliberately follows the XDG layout rather than Application Support, so config sits beside your other dotfile config.',
|
||||
},
|
||||
{
|
||||
id: 'linux',
|
||||
name: 'Linux',
|
||||
credentialStore: 'Secret Service (pure-Rust backend, no libdbus needed)',
|
||||
config: '~/.config/soulseek-rs/config.toml',
|
||||
state: '~/.local/share/soulseek-rs/state',
|
||||
downloads: '~/Downloads/Soulseek',
|
||||
note: 'Honours XDG_CONFIG_HOME and XDG_DATA_HOME when they are set.',
|
||||
},
|
||||
{
|
||||
id: 'windows',
|
||||
name: 'Windows',
|
||||
credentialStore: 'Windows Credential Manager',
|
||||
config: '%APPDATA%\\soulseek-rs\\config\\config.toml',
|
||||
state: '%APPDATA%\\soulseek-rs\\data\\state',
|
||||
downloads: '%USERPROFILE%\\Downloads\\Soulseek',
|
||||
note: 'Uses the standard known-folder locations for roaming app data.',
|
||||
},
|
||||
]
|
||||
|
||||
interface StepProps {
|
||||
n: number
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Step = ({ n, title, children }: StepProps) => (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[auto_1fr] sm:gap-5">
|
||||
<div className="flex size-[34px] items-center justify-center rounded-md border border-line text-sm text-accent">
|
||||
{n}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<h2 className="text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||
{title}
|
||||
</h2>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export const Install = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHead eyebrow="install" title="Three ways in.">
|
||||
One command if you have Rust. Build from source if you’d rather. Or depend on
|
||||
the library and write your own client.
|
||||
</PageHead>
|
||||
|
||||
<Section>
|
||||
<div className="flex flex-col gap-9">
|
||||
<Step n={1} title="Install the client">
|
||||
<p className="text-secondary">
|
||||
Needs a Rust toolchain, available from{' '}
|
||||
<a
|
||||
href={LINKS.rustup}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
rustup.rs
|
||||
</a>
|
||||
. Then:
|
||||
</p>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo install soulseek-rs' },
|
||||
{ t: 'cmd', text: 'soulseek-rs' },
|
||||
]}
|
||||
/>
|
||||
<p className="text-[13px] text-muted">
|
||||
Cargo puts the binary on your PATH. Run <Code>soulseek-rs</Code> and the TUI
|
||||
opens.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href={LINKS.releases}>Prebuilt binaries ↗</Button>
|
||||
</div>
|
||||
<p className="text-[13px] text-muted">
|
||||
No Rust toolchain? Grab a prebuilt archive for your platform from the{' '}
|
||||
<a
|
||||
href={LINKS.releases}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
releases page
|
||||
</a>
|
||||
, unpack it, and put the binary somewhere on your PATH.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Build from source">
|
||||
<p className="text-secondary">
|
||||
Clone the workspace and build a release binary.
|
||||
</p>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'git clone https://github.com/michel/soulseek-rs.git' },
|
||||
{ t: 'cmd', text: 'cd soulseek-rs' },
|
||||
{ t: 'cmd', text: 'cargo build --release' },
|
||||
]}
|
||||
/>
|
||||
<p className="text-[13px] text-muted">
|
||||
You’ll find the binary at <Code>target/release/soulseek-rs</Code>.
|
||||
Prebuilt archives for tagged releases are on the{' '}
|
||||
<a
|
||||
href={LINKS.releases}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
releases page
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Build on the library">
|
||||
<p className="text-secondary">
|
||||
Write your own client or bot on <Code>soulseek-rs-lib</Code>: the protocol
|
||||
implementation, separate from the TUI.
|
||||
</p>
|
||||
<Terminal
|
||||
label="Cargo.toml"
|
||||
lines={[
|
||||
{ t: 'cm', text: '[dependencies]' },
|
||||
{ t: 'code', text: 'soulseek-rs-lib = "6.0.0"' },
|
||||
]}
|
||||
copy={'[dependencies]\nsoulseek-rs-lib = "6.0.0"'}
|
||||
/>
|
||||
<Terminal label="src/main.rs" lines={LIB_LINES} copy={LIB_SRC} />
|
||||
<p className="text-[13px] text-muted">
|
||||
Full API reference on{' '}
|
||||
<a
|
||||
href={LINKS.docsrs}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
docs.rs
|
||||
</a>
|
||||
. Note: v6 moved sharing from one directory to a list. See the{' '}
|
||||
<a
|
||||
href={LINKS.changelog}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-link hover:text-link-hover"
|
||||
>
|
||||
changelog
|
||||
</a>{' '}
|
||||
if you’re upgrading from 5.x.
|
||||
</p>
|
||||
</Step>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<SectionHead eyebrow="being reachable" title="Let peers connect back.">
|
||||
Browsing and downloading are peer-to-peer, so at least one side has to accept an
|
||||
incoming connection.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Prose>
|
||||
<p>
|
||||
With the listener on (the default), soulseek-rs tries to open its listen port
|
||||
on your router via{' '}
|
||||
<b className="font-medium text-primary">UPnP-IGD</b> and{' '}
|
||||
<b className="font-medium text-primary">NAT-PMP</b>. It’s best-effort:
|
||||
if your router has those disabled it’s a no-op, and you forward the port
|
||||
yourself.
|
||||
</p>
|
||||
<p>
|
||||
The port is your <Code>--listener-port</Code> (env <Code>LISTENER_PORT</Code>,
|
||||
default <Code>2234</Code>); it’s renewed automatically and removed on
|
||||
exit. Pass <Code>--disable-listener</Code> to turn it off. Check your own
|
||||
network without launching the client:
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Terminal lines={[{ t: 'cmd', text: 'soulseek-rs portmap' }]} />
|
||||
</div>
|
||||
</Prose>
|
||||
<Callout tone="warn" title="honest about limits">
|
||||
<p>
|
||||
If both you and a peer are behind routers with no forwarded port, browsing
|
||||
that peer can’t work, that’s a fundamental Soulseek/peer-to-peer
|
||||
limitation, not a bug. Forward <Code>--listener-port</Code>, or let{' '}
|
||||
<Code>portmap</Code> try UPnP.
|
||||
</p>
|
||||
</Callout>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section id="platforms">
|
||||
<SectionHead eyebrow="platform notes" title="Where things live.">
|
||||
Sensible defaults per OS, all overridable. Set <Code>SOULSEEK_CONFIG_DIR</Code> or{' '}
|
||||
<Code>SOULSEEK_STATE_DIR</Code> to relocate either one, which also makes a
|
||||
portable install possible.
|
||||
</SectionHead>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
{PLATFORMS.map((platform) => (
|
||||
<div
|
||||
key={platform.id}
|
||||
id={platform.id}
|
||||
className="scroll-mt-20 rounded-md border border-hairline bg-panel p-[18px] sm:p-[22px]"
|
||||
>
|
||||
<h3 className="mb-4 text-heading leading-[var(--text-heading--line-height)] font-medium">
|
||||
{platform.name}
|
||||
</h3>
|
||||
<dl className="grid grid-cols-1 gap-x-3.5 gap-y-0.5 sm:grid-cols-[auto_1fr] sm:gap-y-2">
|
||||
{(
|
||||
[
|
||||
['Password', platform.credentialStore, false],
|
||||
['Config', platform.config, true],
|
||||
['State', platform.state, true],
|
||||
['Downloads', platform.downloads, true],
|
||||
] as const
|
||||
).map(([label, value, mono]) => (
|
||||
<div key={label} className="contents">
|
||||
<dt className="pt-2 text-[11px] uppercase tracking-[var(--tracking-label)] text-secondary sm:pt-0.5">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="min-w-0 text-[12.5px] leading-[19px] text-primary">
|
||||
{mono ? (
|
||||
<code className="font-mono text-xs break-all">{value}</code>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<p className="mt-4 border-t border-hairline pt-3.5 text-[12.5px] leading-5 text-secondary">
|
||||
{platform.note}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Cols start className="mt-7">
|
||||
<FeatureCard title="Secrets">
|
||||
Your password goes to the OS keychain, never a plain-text file. If you would
|
||||
rather it never be stored at all, have <code>password_cmd</code> shell out to
|
||||
your own password manager and print it on demand.
|
||||
</FeatureCard>
|
||||
<FeatureCard title="Settings and state">
|
||||
<code>config.toml</code> is layered: flags beat environment variables, which
|
||||
beat the file, which beats the defaults. State (searches, downloads, rooms) is
|
||||
versioned JSON and restores on restart; anything unreadable is set aside as{' '}
|
||||
<code>.bak</code> instead of being overwritten.
|
||||
</FeatureCard>
|
||||
</Cols>
|
||||
</Section>
|
||||
|
||||
<Section band>
|
||||
<SectionHead eyebrow="uninstall" title="Removing it completely.">
|
||||
Three things exist on disk: the binary, the config, and the state. Nothing else is
|
||||
written, and there is no telemetry to opt out of.
|
||||
</SectionHead>
|
||||
<Cols start>
|
||||
<Terminal
|
||||
label="macOS · Linux"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||
{ t: 'cm', text: '# config, state and any cached shares' },
|
||||
{
|
||||
t: 'cmd',
|
||||
text: 'rm -rf ~/.config/soulseek-rs ~/.local/share/soulseek-rs',
|
||||
},
|
||||
{ t: 'cm', text: '# Linux: drop the saved password' },
|
||||
{ t: 'cmd', text: 'secret-tool clear service soulseek-rs' },
|
||||
]}
|
||||
/>
|
||||
<Terminal
|
||||
label="Windows · PowerShell"
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'cargo uninstall soulseek-rs' },
|
||||
{
|
||||
t: 'cmd',
|
||||
text: 'Remove-Item -Recurse -Force $env:APPDATA\\soulseek-rs',
|
||||
},
|
||||
{ t: 'cm', text: '# then remove the soulseek-rs entry from' },
|
||||
{ t: 'cm', text: '# Credential Manager > Windows Credentials' },
|
||||
]}
|
||||
/>
|
||||
</Cols>
|
||||
<div className="mt-5">
|
||||
<Callout title="two things it will not touch">
|
||||
<p>
|
||||
Your downloads and your shared directories are yours: they stay exactly where
|
||||
they are. On macOS, delete the keychain entry for <code>soulseek-rs</code> in
|
||||
Keychain Access if you saved a password.
|
||||
</p>
|
||||
</Callout>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Eyebrow, Wrap } from '@/components/ui/layout'
|
||||
import { Terminal } from '@/components/ui/terminal'
|
||||
|
||||
export const NotFound = () => {
|
||||
return (
|
||||
<section className="py-16">
|
||||
<Wrap>
|
||||
<div className="flex max-w-[560px] flex-col gap-3.5">
|
||||
<Eyebrow>404</Eyebrow>
|
||||
<h1 className="text-[26px] leading-[34px] font-medium sm:text-title sm:leading-[var(--text-title--line-height)]">
|
||||
No such path.
|
||||
</h1>
|
||||
<p className="text-secondary">
|
||||
That page doesn’t exist. The four that do are in the nav above.
|
||||
</p>
|
||||
<Terminal
|
||||
lines={[
|
||||
{ t: 'cmd', text: 'soulseek-rs --help' },
|
||||
{ t: 'cm', text: '# no such page: check the nav, or start here' },
|
||||
]}
|
||||
copy="soulseek-rs --help"
|
||||
/>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2 sm:gap-3">
|
||||
<Button href="/" variant="accent">
|
||||
Home
|
||||
</Button>
|
||||
<Button href="/docs">Read the docs</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Wrap>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export const THEMES = ['light', 'dark', 'system'] as const
|
||||
export type Theme = (typeof THEMES)[number]
|
||||
export type ResolvedTheme = Exclude<Theme, 'system'>
|
||||
|
||||
export const isTheme = (value: unknown): value is Theme =>
|
||||
typeof value === 'string' && (THEMES as readonly string[]).includes(value)
|
||||
|
||||
export interface ThemeContextValue {
|
||||
/** What the user picked — may be 'system'. */
|
||||
theme: Theme
|
||||
/** What is actually painted right now — never 'system'. */
|
||||
resolvedTheme: ResolvedTheme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
/*
|
||||
* Split from the provider so this module exports only values, which keeps the
|
||||
* provider file component-only for react-refresh.
|
||||
*/
|
||||
export const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
/** Matches the key the design's own inline boot script reads. */
|
||||
export const THEME_STORAGE_KEY = 'ssrs-theme'
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react'
|
||||
|
||||
import { ThemeContext, type Theme, type ThemeContextValue } from './theme-context'
|
||||
import {
|
||||
readResolvedTheme,
|
||||
readTheme,
|
||||
serverResolvedTheme,
|
||||
serverTheme,
|
||||
subscribe,
|
||||
writeTheme,
|
||||
} from './theme-store'
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }: ThemeProviderProps) => {
|
||||
const theme = useSyncExternalStore(subscribe, readTheme, serverTheme)
|
||||
const resolvedTheme = useSyncExternalStore(
|
||||
subscribe,
|
||||
readResolvedTheme,
|
||||
serverResolvedTheme,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
root.dataset['theme'] = resolvedTheme
|
||||
// Paints native widgets (scrollbars, form controls) to match, which the
|
||||
// attribute alone does not do.
|
||||
root.style.colorScheme = resolvedTheme
|
||||
}, [resolvedTheme])
|
||||
|
||||
const setTheme = useCallback((next: Theme) => {
|
||||
writeTheme(next)
|
||||
}, [])
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedTheme, setTheme }),
|
||||
[theme, resolvedTheme, setTheme],
|
||||
)
|
||||
|
||||
return <ThemeContext value={value}>{children}</ThemeContext>
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { isTheme, THEME_STORAGE_KEY, type ResolvedTheme, type Theme } from './theme-context'
|
||||
|
||||
const DARK_QUERY = '(prefers-color-scheme: dark)'
|
||||
|
||||
/*
|
||||
* The theme lives outside React: the boot script in index.html resolves and
|
||||
* paints it before the bundle loads, and the OS can change it at any time.
|
||||
* That makes it an external store, which is what useSyncExternalStore is for
|
||||
* — and it is the piece that gives correct hydration for free.
|
||||
*
|
||||
* The server snapshots below must match what the prerendered HTML ships
|
||||
* (`<html data-theme="dark">`). React renders the server snapshot for the
|
||||
* hydration pass, then immediately re-renders with the real one, so a visitor
|
||||
* whose actual theme is light never sees a mismatch, only a corrected render.
|
||||
*/
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
/** Notifies subscribers after a local write; OS changes come via matchMedia. */
|
||||
const emit = () => {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
export const subscribe = (onChange: () => void): (() => void) => {
|
||||
listeners.add(onChange)
|
||||
const query = window.matchMedia(DARK_QUERY)
|
||||
query.addEventListener('change', onChange)
|
||||
// Another tab changing the preference should move this one too.
|
||||
window.addEventListener('storage', onChange)
|
||||
return () => {
|
||||
listeners.delete(onChange)
|
||||
query.removeEventListener('change', onChange)
|
||||
window.removeEventListener('storage', onChange)
|
||||
}
|
||||
}
|
||||
|
||||
export const readTheme = (): Theme => {
|
||||
try {
|
||||
const stored = localStorage.getItem(THEME_STORAGE_KEY)
|
||||
return isTheme(stored) ? stored : 'system'
|
||||
} catch {
|
||||
// Safari in private mode throws on localStorage access.
|
||||
return 'system'
|
||||
}
|
||||
}
|
||||
|
||||
export const readResolvedTheme = (): ResolvedTheme => {
|
||||
const theme = readTheme()
|
||||
if (theme !== 'system') return theme
|
||||
return window.matchMedia(DARK_QUERY).matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
/** Both must agree with the `data-theme` baked into index.html. */
|
||||
export const serverTheme = (): Theme => 'system'
|
||||
export const serverResolvedTheme = (): ResolvedTheme => 'dark'
|
||||
|
||||
export const writeTheme = (next: Theme): void => {
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, next)
|
||||
} catch {
|
||||
// Not persisting is survivable; the theme still applies this session.
|
||||
}
|
||||
emit()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react'
|
||||
|
||||
import { ThemeContext, type ThemeContextValue } from './theme-context'
|
||||
|
||||
export const useTheme = (): ThemeContextValue => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (context === null)
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
return context
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
/** Injected by `define` in vite.config.ts from the workspace Cargo.toml. */
|
||||
declare const __APP_VERSION__: string
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
|
||||
/* Strictest. Every flag below is on deliberately — see README. */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* No baseUrl — deprecated in TS 6. `paths` resolves relative to this file. */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
/* scripts/ is run straight from source by Bun, which needs the extension. */
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"allowUnreachableCode": false,
|
||||
"allowUnusedLabels": false,
|
||||
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"eslint.config.ts",
|
||||
"scripts/**/*.ts",
|
||||
"types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* eslint-plugin-jsx-a11y ships no types, and TS's inference from its JS picks
|
||||
* up the legacy eslintrc `configs` shape (plugins as string[]) instead of the
|
||||
* flat-config one. Declaring the flat export is enough to type the one thing
|
||||
* eslint.config.ts touches.
|
||||
*/
|
||||
declare module 'eslint-plugin-jsx-a11y' {
|
||||
import type { Linter } from 'eslint'
|
||||
|
||||
export const flatConfigs: {
|
||||
recommended: Linter.Config
|
||||
strict: Linter.Config
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
/*
|
||||
* Read the version out of the workspace manifest rather than keeping a copy in
|
||||
* TypeScript. release-plz bumps Cargo.toml; a hand-written constant here would
|
||||
* silently advertise the previous release forever.
|
||||
*/
|
||||
const cargoToml = readFileSync(
|
||||
fileURLToPath(new URL('../Cargo.toml', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
const versionMatch = /^\s*version\s*=\s*"([^"]+)"/m.exec(cargoToml)
|
||||
if (versionMatch?.[1] === undefined)
|
||||
throw new Error('could not read version from ../Cargo.toml')
|
||||
const VERSION = `v${versionMatch[1]}`
|
||||
|
||||
// Project sites are served from https://<user>.github.io/<repo>/, so every
|
||||
// asset URL needs that prefix baked in at build time.
|
||||
// hardcoded. Point a custom domain at the site and this becomes '/'
|
||||
// — one edit here plus the matching `basename` in src/main.tsx.
|
||||
const BASE = '/soulseek-rs/'
|
||||
|
||||
export default defineConfig({
|
||||
base: BASE,
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(VERSION),
|
||||
},
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
// Small site; a warning at the default 500 kB would be noise. A jump past
|
||||
// this means something heavy got imported by accident.
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user