commit 586b599d4c60f935fbeb279186c3f409fa939155 Author: Moon.eth Date: Wed Jul 29 09:16:38 2026 +0900 initial commit diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..2e36142 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "plspace", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d326d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.DS_Store +*.local +.vite/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f51f70 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# plspace + +*It's always Pleroma.* + +A static, client-side frontend for Pleroma that looks and behaves like MySpace +circa 2005. + +Point it at your server. It talks to that server's REST API directly from the +browser — there is no plspace backend, and `dist/` is a folder of static files +you can host anywhere. + +## What it looks like + +| MySpace | plspace | +| --- | --- | +| Profile page: photo, headline, vitals, Contacting box, Interests table | `#/@user@server` | +| Latest Blog Entries with "(view more)" | Account statuses, headline list | +| Blurbs: "About me", "Who I'd like to meet" | The bio, split on a heading if you wrote one | +| Friend Space grid | Followers | +| Mail Center, Friend Request Manager | Notifications, follow requests | +| Bulletin Space | The local timeline | +| Kudos | Favourites | +| Custom profile layouts | CSS in a profile field named `css` | + +## Running it + +```bash +npm install && npm run dev +``` + +```bash +npm run build +``` + +`npm run build` type-checks and then bundles to `dist/`. Because routing is +hash-based, the output works from any path — a subdirectory, an S3 bucket, +GitHub Pages — with no server rewrite rules. + +## Signing in + +plspace registers itself as an OAuth app on your server the first time you sign +in there, then redirects you to that server's own consent screen. Your password +is never entered into plspace; it only ever receives an access token, which is +stored in `localStorage` and used directly from your browser. + +PKCE is used where the server supports it, with an automatic fallback for +servers that don't. + +You can also browse without signing in — click **Just look around**. Note that +many servers set `restrict_unauthenticated` and refuse timeline reads from +anonymous callers; the suggested servers on the sign-in page are ones verified +to allow it. + +## Making it yours + +Settings has a CSS editor, five starter layouts, and a full class reference. +Everything about the appearance is overridable — see +[`src/styles/README.md`](src/styles/README.md) for the contract. In short: +no CSS framework, no Svelte scoped styles, no hashed class names, every colour +and metric is a custom property, and user CSS is always last in the cascade so +plain single-class selectors win without `!important`. + +There is no built-in dark mode. A dark theme is just token overrides, so it ships +as a preset (**Midnight**, **Terminal**) you can apply, edit or replace, rather +than as a toggle you can't. + +To publish a layout other people see on your profile, put CSS in a profile field +named `css` on your server. It is scoped to your profile's subtree and filtered +before it is applied. + +Your profile fields also drive the profile page: name one `Music`, `Movies`, +`Television`, `Books` or `Heroes` and it fills the Interests table; name one +`Mood`, `Location`, `Gender` or `Headline` and it fills the block beside your +photo. + +## Layout + +``` +src/ + lib/ + api/ client (fetch, Link-header pagination), OAuth, endpoints, entity types + stores/ session, Feed (cursor pagination), theme (user + profile CSS) + util/ HTML sanitizing, 2005-flavoured date formats, account -> profile mapping + router.svelte.ts + themes.ts starter layouts, written only in terms of tokens + components/ + chrome/ header, nav, footer + common/ Module, RichText, Avatar, Pager, TabBar + profile/ identity, contacting box, interests, details, friend space + blog/ entry, attachments, poll, preview card, composer + people/ person row and list + routes/ Home, Profile, Timeline, StatusPage, Mail, Browse, Search, Compose, Login, Settings + styles/ tokens, base, layout, chrome, module, profile, blog, forms +``` + +## Safety notes + +All HTML from the API — post content, bios, profile field values — is sanitized +with DOMPurify before it reaches `{@html}`, in exactly one component +(`RichText.svelte`) plus the profile-field helper. Mention and hashtag links are +rewritten to in-app routes; every other link gets `target="_blank"` with +`rel="noopener noreferrer"`. + +## Compatibility + +Written against the standard `/api/v1` REST API and tested against live servers. +Anything implementations differ on degrades rather than fails: `/api/v2/instance` +falls back to v1, `/api/v1/accounts/lookup` falls back to search, pagination +falls back to the last item's id when a server drops the `Link` header, and +per-account privacy flags are read in both their spellings. Other software +speaking the same API therefore works, but Pleroma is what this targets. diff --git a/index.html b/index.html new file mode 100644 index 0000000..2a73da2 --- /dev/null +++ b/index.html @@ -0,0 +1,25 @@ + + + + + + + + + + plspace | it's always Pleroma + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..af6f8cf --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1361 @@ +{ + "name": "plspace", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plspace", + "version": "0.1.0", + "dependencies": { + "dompurify": "^3.4.12" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@tsconfig/svelte": "^5.0.4", + "@types/node": "^26.1.2", + "svelte": "^5.56.8", + "svelte-check": "^4.3.3", + "typescript": "^5.9.3", + "vite": "^8.1.5" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.0.tgz", + "integrity": "sha512-kDoONqMa+VnZ4vvvu/ZUurpJ4gkZU57e7g69qpNgWhYcZFPUHZM2CEMKm+cG6ufDVALbjMvfmMjFVqaK7uEMnA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^2.0.0-alpha.3", + "@emnapi/runtime": "^2.0.0-alpha.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.2.0.tgz", + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.2.tgz", + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.24", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.24.tgz", + "integrity": "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz", + "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d4ec31 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "plspace", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "A MySpace-flavored static frontend for Pleroma", + "scripts": { + "dev": "vite", + "build": "svelte-check --tsconfig ./tsconfig.json && vite build", + "build:only": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^7.2.0", + "@tsconfig/svelte": "^5.0.4", + "@types/node": "^26.1.2", + "svelte": "^5.56.8", + "svelte-check": "^4.3.3", + "typescript": "^5.9.3", + "vite": "^8.1.5" + }, + "dependencies": { + "dompurify": "^3.4.12" + } +} diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..6279e2d --- /dev/null +++ b/src/App.svelte @@ -0,0 +1,98 @@ + + +
+ + + + {#if !booted} +
+

Starting up…

+
+ {:else if !session.host && !ALWAYS_AVAILABLE.has(route.name)} + + {:else if route.name === 'home'} + + {:else if route.name === 'profile'} + + {:else if route.name === 'profile.blog'} + + {:else if route.name === 'profile.friends'} + + {:else if route.name === 'profile.pics'} + + {:else if route.name === 'timeline'} + + {:else if route.name === 'tag'} + + {:else if route.name === 'blog.entry'} + + {:else if route.name === 'mail'} + + {:else if route.name === 'mail.folder'} + + {:else if route.name === 'browse'} + + {:else if route.name === 'search'} + + {:else if route.name === 'compose'} + + {:else if route.name === 'login'} + + {:else if route.name === 'settings'} + + {:else} + + {/if} + + +
diff --git a/src/assets/plspace-logo.webp b/src/assets/plspace-logo.webp new file mode 100644 index 0000000..c4a192b Binary files /dev/null and b/src/assets/plspace-logo.webp differ diff --git a/src/components/blog/Attachments.svelte b/src/components/blog/Attachments.svelte new file mode 100644 index 0000000..41613ae --- /dev/null +++ b/src/components/blog/Attachments.svelte @@ -0,0 +1,85 @@ + + +{#if attachments.length > 0} + +{/if} diff --git a/src/components/blog/BlogEntry.svelte b/src/components/blog/BlogEntry.svelte new file mode 100644 index 0000000..6a66ba6 --- /dev/null +++ b/src/components/blog/BlogEntry.svelte @@ -0,0 +1,261 @@ + + +
+ {#if booster} +

+ {displayNameOf(booster)} reposted this +

+ {/if} + +
+ {#if !compact} +
+ +
+ {/if} + +
+ +
+ {#if entry.spoiler_text} +
+ {entry.spoiler_text} + + {#if entry.media_attachments.length > 0} + + {/if} +
+ {:else} + + {#if entry.media_attachments.length > 0} + + {/if} + {/if} + + {#if entry.poll} + + {/if} + + {#if entry.card && entry.media_attachments.length === 0} + + {/if} + + {#if actionError} + + {/if} + + +
+
diff --git a/src/components/blog/BlogList.svelte b/src/components/blog/BlogList.svelte new file mode 100644 index 0000000..5be4f47 --- /dev/null +++ b/src/components/blog/BlogList.svelte @@ -0,0 +1,36 @@ + + + + + diff --git a/src/components/blog/Composer.svelte b/src/components/blog/Composer.svelte new file mode 100644 index 0000000..1910011 --- /dev/null +++ b/src/components/blog/Composer.svelte @@ -0,0 +1,186 @@ + + +{#if session.signedIn} +
+ {#if error} + + {/if} + + {#if showWarning} +
+ + +
+ {/if} + + + + + {#if attachments.length > 0} +
    + {#each attachments as media (media.id)} +
  • + {media.description + +
  • + {/each} +
+ {/if} + +
+ + + + + + + + {remaining} + + +
+
+{:else} +

+ Sign in to post. +

+{/if} diff --git a/src/components/blog/PollView.svelte b/src/components/blog/PollView.svelte new file mode 100644 index 0000000..704901e --- /dev/null +++ b/src/components/blog/PollView.svelte @@ -0,0 +1,42 @@ + + +
+ {#each poll.options as option, index (index)} +
+
+ {option.title} + {share(option.votes_count)}% +
+
+ +
+
+ {/each} + +

+ {formatCount(total)} vote{total === 1 ? '' : 's'} + {#if poll.expired} + · closed + {:else if poll.expires_at} + · closes {relativeTime(poll.expires_at).replace(' ago', ' from now')} + {/if} +

+
diff --git a/src/components/blog/PreviewCardView.svelte b/src/components/blog/PreviewCardView.svelte new file mode 100644 index 0000000..9efe345 --- /dev/null +++ b/src/components/blog/PreviewCardView.svelte @@ -0,0 +1,31 @@ + + + + {#if card.image} + + {/if} + + {card.title} + {#if card.description} + {card.description} + {/if} + {host} + + diff --git a/src/components/chrome/SiteFooter.svelte b/src/components/chrome/SiteFooter.svelte new file mode 100644 index 0000000..79e4ad1 --- /dev/null +++ b/src/components/chrome/SiteFooter.svelte @@ -0,0 +1,36 @@ + + +
+ + + +
diff --git a/src/components/chrome/SiteHeader.svelte b/src/components/chrome/SiteHeader.svelte new file mode 100644 index 0000000..7e8c6bb --- /dev/null +++ b/src/components/chrome/SiteHeader.svelte @@ -0,0 +1,91 @@ + + + diff --git a/src/components/chrome/SiteNav.svelte b/src/components/chrome/SiteNav.svelte new file mode 100644 index 0000000..49c6d75 --- /dev/null +++ b/src/components/chrome/SiteNav.svelte @@ -0,0 +1,56 @@ + + + diff --git a/src/components/common/Avatar.svelte b/src/components/common/Avatar.svelte new file mode 100644 index 0000000..544820f --- /dev/null +++ b/src/components/common/Avatar.svelte @@ -0,0 +1,66 @@ + + +{#snippet image()} + {#if src && !failed} + (failed = true)} + /> + {:else} + + {/if} +{/snippet} + +{#if plain} + {@render image()} +{:else} + + {@render image()} + +{/if} diff --git a/src/components/common/Module.svelte b/src/components/common/Module.svelte new file mode 100644 index 0000000..4780734 --- /dev/null +++ b/src/components/common/Module.svelte @@ -0,0 +1,50 @@ + + +
+ {#if title} +

+ {title} + {#if action} + {@render action()} + {/if} +

+ {/if} +
+ {@render children()} +
+
diff --git a/src/components/common/Pager.svelte b/src/components/common/Pager.svelte new file mode 100644 index 0000000..cedc633 --- /dev/null +++ b/src/components/common/Pager.svelte @@ -0,0 +1,51 @@ + + +{#if feed.error} + +{/if} + +{#if !feed.initialized && feed.loading} +

Loading…

+{:else if feed.isEmpty} +

{emptyText}

+{:else if !feed.exhausted} +
+ +
+{:else if feed.items.length > 0 && endText} +

{endText}

+{/if} diff --git a/src/components/common/RichText.svelte b/src/components/common/RichText.svelte new file mode 100644 index 0000000..83cbc62 --- /dev/null +++ b/src/components/common/RichText.svelte @@ -0,0 +1,38 @@ + + +{#if rendered} +
+ + {@html rendered} +
+{/if} diff --git a/src/components/common/TabBar.svelte b/src/components/common/TabBar.svelte new file mode 100644 index 0000000..1b20a36 --- /dev/null +++ b/src/components/common/TabBar.svelte @@ -0,0 +1,30 @@ + + + diff --git a/src/components/people/PersonList.svelte b/src/components/people/PersonList.svelte new file mode 100644 index 0000000..32da524 --- /dev/null +++ b/src/components/people/PersonList.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/src/components/people/PersonRow.svelte b/src/components/people/PersonRow.svelte new file mode 100644 index 0000000..61f5e96 --- /dev/null +++ b/src/components/people/PersonRow.svelte @@ -0,0 +1,55 @@ + + +
  • + + + + +
    + + + {@html name} + +
    {fullHandle(account, session.host)}
    + + {#if account.note} + + {/if} + +

    + {formatCount(account.statuses_count)} entries · + {formatCount(account.followers_count)} friends + {#if account.last_status_at} + · active {relativeTime(account.last_status_at)} + {/if} +

    +
    + + {#if actions} +
    {@render actions()}
    + {/if} +
  • diff --git a/src/components/profile/ContactBox.svelte b/src/components/profile/ContactBox.svelte new file mode 100644 index 0000000..271857b --- /dev/null +++ b/src/components/profile/ContactBox.svelte @@ -0,0 +1,140 @@ + + + + {#if error} + + {/if} + + + + {#if relationship?.followed_by && !isSelf} +

    {firstName} has you on their friends list.

    + {/if} +
    diff --git a/src/components/profile/DetailsTable.svelte b/src/components/profile/DetailsTable.svelte new file mode 100644 index 0000000..e283ef8 --- /dev/null +++ b/src/components/profile/DetailsTable.svelte @@ -0,0 +1,39 @@ + + +{#if fields.length > 0} + + + + {#each fields as field (field.name)} + + + + + {/each} + +
    {field.name} + {#if field.verified} + + {/if} + + {@html field.value} +
    +
    +{/if} diff --git a/src/components/profile/FriendSpace.svelte b/src/components/profile/FriendSpace.svelte new file mode 100644 index 0000000..0273d36 --- /dev/null +++ b/src/components/profile/FriendSpace.svelte @@ -0,0 +1,81 @@ + + + + {#snippet action()} + [view all] + {/snippet} + + + {#if countHidden} +

    {ownerName} keeps their friend count private.

    + {:else} +

    + {ownerName} has {formatCount(total)} + friend{total === 1 ? '' : 's'}. +

    + {/if} + + {#if hidden} +

    This friends list is private.

    + {:else if loading && friends.length === 0} +

    Loading friends…

    + {:else if friends.length === 0} +

    No friends to show yet.

    + {:else} + + {/if} +
    diff --git a/src/components/profile/InterestsTable.svelte b/src/components/profile/InterestsTable.svelte new file mode 100644 index 0000000..352eb37 --- /dev/null +++ b/src/components/profile/InterestsTable.svelte @@ -0,0 +1,35 @@ + + +{#if interests.length > 0} + + + + {#each interests as entry (entry.row)} + + + + + {/each} + +
    {entry.row} + + {@html entry.value} +
    +
    +{/if} diff --git a/src/components/profile/ProfileIdentity.svelte b/src/components/profile/ProfileIdentity.svelte new file mode 100644 index 0000000..538bbd8 --- /dev/null +++ b/src/components/profile/ProfileIdentity.svelte @@ -0,0 +1,107 @@ + + +
    +
    + {#if photo} + + {displayNameOf(account)} + + {:else} + + {/if} + View more pics +
    + +
    +

    {profile.headline}

    + +
    + {#if profile.gender} +
    Gender
    +
    {profile.gender}
    + {/if} + + {#if accountAge !== null} +
    Age
    +
    + {accountAge} years old + {#if profile.age === null} + (on this server) + {/if} +
    + {/if} + + {#if profile.location} +
    Location
    +
    {profile.location}
    + {/if} + +
    Last active
    +
    + Last active:
    + {account.last_status_at ? relativeTime(account.last_status_at) : 'unknown'} +
    + +
    Member since
    +
    + Member since: {shortDate(account.created_at)} +
    +
    + + {#if profile.mood} +

    + Mood: {profile.mood} +

    + {/if} + + + +

    + Handle: + {handle} + {#if account.bot} + bot + {/if} + {#if account.locked} + private + {/if} + {#each account.roles ?? [] as role (role.id)} + {role.name} + {/each} +

    +
    +
    diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 0000000..ff29e2d --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,244 @@ +/** + * Thin fetch wrapper for Mastodon-compatible REST APIs. + * + * Deliberately dependency-free and stateless apart from the host/token it is + * constructed with, so it can be reused for logged-out browsing of an arbitrary + * instance as well as for the signed-in session. + */ + +export class ApiError extends Error { + readonly status: number + readonly url: string + readonly body: unknown + + constructor(status: number, url: string, body: unknown, message?: string) { + super(message ?? `${status} from ${url}`) + this.name = 'ApiError' + this.status = status + this.url = url + this.body = body + } + + /** True when re-authenticating is likely to fix it. */ + get isAuthFailure(): boolean { + return this.status === 401 || this.status === 403 + } +} + +/** Cursor links parsed out of the RFC 5988 `Link` response header. */ +export interface PageLinks { + /** Older results (`?max_id=…`). */ + next?: string + /** Newer results (`?min_id=…`). */ + prev?: string + maxId?: string + minId?: string + sinceId?: string +} + +export interface Page { + items: T[] + links: PageLinks +} + +export type QueryValue = string | number | boolean | undefined | null | string[] +export type Query = Record + +export interface RequestOptions { + method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' + query?: Query + body?: unknown + /** Send as multipart instead of JSON (media uploads). */ + form?: FormData + signal?: AbortSignal + /** Override the instance token for this call. */ + token?: string | null + headers?: Record +} + +/** + * Mastodon paginates with a `Link` header rather than in the body. Pleroma + * emits the same header but occasionally omits `rel="prev"`, and some servers + * behind a CORS proxy strip it entirely — callers must cope with an empty + * result here by falling back to the last item's id. + */ +export function parseLinkHeader(header: string | null): PageLinks { + const links: PageLinks = {} + if (!header) return links + + for (const part of header.split(/,\s*(?=<)/)) { + const match = /^<([^>]+)>\s*;\s*(.+)$/.exec(part.trim()) + if (!match) continue + + const [, url, params] = match + const rel = /rel\s*=\s*"?([^";]+)"?/.exec(params)?.[1] + if (rel !== 'next' && rel !== 'prev') continue + + links[rel] = url + + let parsed: URL + try { + parsed = new URL(url) + } catch { + continue + } + const maxId = parsed.searchParams.get('max_id') + const minId = parsed.searchParams.get('min_id') + const sinceId = parsed.searchParams.get('since_id') + if (rel === 'next' && maxId) links.maxId = maxId + if (rel === 'prev' && minId) links.minId = minId + if (rel === 'prev' && sinceId) links.sinceId = sinceId + } + + return links +} + +/** `https://example.social/` / `Example.Social` / `@user@example.social` -> `example.social`. */ +export function normalizeHost(input: string): string { + let value = input.trim().toLowerCase() + if (!value) return '' + // Accept a full webfinger handle and keep only the domain part. + if (value.includes('@')) value = value.slice(value.lastIndexOf('@') + 1) + value = value.replace(/^https?:\/\//, '') + value = value.replace(/\/.*$/, '') + return value +} + +function buildQuery(query: Query | undefined): string { + if (!query) return '' + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value)) { + for (const entry of value) params.append(`${key}[]`, entry) + } else { + params.set(key, String(value)) + } + } + const serialized = params.toString() + return serialized ? `?${serialized}` : '' +} + +export class ApiClient { + readonly host: string + private token: string | null + + constructor(host: string, token: string | null = null) { + this.host = normalizeHost(host) + this.token = token + } + + get origin(): string { + return `https://${this.host}` + } + + get authenticated(): boolean { + return Boolean(this.token) + } + + setToken(token: string | null): void { + this.token = token + } + + withToken(token: string | null): ApiClient { + return new ApiClient(this.host, token) + } + + private url(path: string, query?: Query): string { + const suffix = path.startsWith('/') ? path : `/${path}` + return `${this.origin}${suffix}${buildQuery(query)}` + } + + private headers(options: RequestOptions): Headers { + const headers = new Headers(options.headers) + headers.set('Accept', 'application/json') + const token = options.token !== undefined ? options.token : this.token + if (token) headers.set('Authorization', `Bearer ${token}`) + if (options.body !== undefined && !options.form) { + headers.set('Content-Type', 'application/json') + } + return headers + } + + /** Perform a request and return the parsed body plus the raw response. */ + async raw(path: string, options: RequestOptions = {}): Promise<{ data: T; response: Response }> { + const url = this.url(path, options.query) + let response: Response + try { + response = await fetch(url, { + method: options.method ?? 'GET', + headers: this.headers(options), + body: options.form ?? (options.body !== undefined ? JSON.stringify(options.body) : undefined), + signal: options.signal, + // Mastodon tokens ride in the Authorization header; never send cookies + // cross-origin, which would also trip CORS preflight on most servers. + credentials: 'omit', + mode: 'cors', + }) + } catch (cause) { + // A CORS rejection and an offline browser are indistinguishable here. + throw new ApiError( + 0, + url, + null, + `Could not reach ${this.host}. It may be offline, or it may not allow browser apps to connect (CORS).`, + ) + } + + const text = await response.text() + let data: unknown = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = text + } + } + + if (!response.ok) { + const message = + (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string' + ? data.error + : undefined) ?? `${response.status} ${response.statusText}` + throw new ApiError(response.status, url, data, message) + } + + return { data: data as T, response } + } + + async get(path: string, query?: Query, options: RequestOptions = {}): Promise { + const { data } = await this.raw(path, { ...options, method: 'GET', query }) + return data + } + + async post(path: string, body?: unknown, options: RequestOptions = {}): Promise { + const { data } = await this.raw(path, { ...options, method: 'POST', body }) + return data + } + + async patch(path: string, body?: unknown, options: RequestOptions = {}): Promise { + const { data } = await this.raw(path, { ...options, method: 'PATCH', body }) + return data + } + + async delete(path: string, options: RequestOptions = {}): Promise { + const { data } = await this.raw(path, { ...options, method: 'DELETE' }) + return data + } + + /** GET a collection endpoint, returning both items and cursor links. */ + async page(path: string, query?: Query, options: RequestOptions = {}): Promise> { + const { data, response } = await this.raw(path, { ...options, method: 'GET', query }) + const items = Array.isArray(data) ? data : [] + const links = parseLinkHeader(response.headers.get('Link')) + + // Fallback for servers that drop the Link header: derive `max_id` from the + // last item so "see more" keeps working. + if (!links.maxId && items.length > 0) { + const last = items[items.length - 1] as { id?: string } + if (last && typeof last.id === 'string') links.maxId = last.id + } + + return { items, links } + } +} diff --git a/src/lib/api/endpoints.ts b/src/lib/api/endpoints.ts new file mode 100644 index 0000000..27d7263 --- /dev/null +++ b/src/lib/api/endpoints.ts @@ -0,0 +1,289 @@ +/** + * Typed wrappers around the endpoints this frontend actually uses. + * + * Grouped by the MySpace-era concept they back, because that is how the UI + * thinks about them: friends (follows), blog entries (statuses), comments + * (replies), the mail centre (notifications). + */ + +import type { ApiClient, Page, Query } from './client' +import { ApiError } from './client' +import type { + Account, + Context, + CredentialAccount, + InstanceInfo, + MediaAttachment, + Notification, + Relationship, + SearchResults, + Status, + StatusVisibility, +} from './types' + +export interface Cursor { + max_id?: string + min_id?: string + since_id?: string + limit?: number +} + +/* ------------------------------------------------------------------ server */ + +export async function fetchInstance(api: ApiClient): Promise { + // v2 is richer but Pleroma/Akkoma only reliably serve v1. + try { + return await api.get('/api/v2/instance') + } catch (cause) { + if (cause instanceof ApiError && cause.status !== 0) { + return api.get('/api/v1/instance') + } + throw cause + } +} + +export function instanceDomain(instance: InstanceInfo | null, fallback: string): string { + return instance?.domain ?? instance?.uri ?? fallback +} + +export interface InstanceStat { + label: string + value: number +} + +/** + * Normalize the two instance shapes into a stat list. + * + * v1 published user/status/domain counts; v2 dropped all of it except monthly + * actives, so the right rail shows whichever the server actually returned + * rather than a row of zeroes. + */ +export function instanceStats(instance: InstanceInfo | null): InstanceStat[] { + if (!instance) return [] + + if (instance.stats) { + return [ + { label: 'Members', value: instance.stats.user_count }, + { label: 'Entries', value: instance.stats.status_count }, + { label: 'Known servers', value: instance.stats.domain_count }, + ] + } + + const activeMonth = instance.usage?.users?.active_month + return typeof activeMonth === 'number' ? [{ label: 'Active this month', value: activeMonth }] : [] +} + +export function instanceThumbnail(instance: InstanceInfo | null): string | null { + const thumbnail = instance?.thumbnail + if (!thumbnail) return null + return typeof thumbnail === 'string' ? thumbnail : (thumbnail.url ?? null) +} + +/* ------------------------------------------------------------------ people */ + +export function verifyCredentials(api: ApiClient): Promise { + return api.get('/api/v1/accounts/verify_credentials') +} + +export function fetchAccount(api: ApiClient, id: string): Promise { + return api.get(`/api/v1/accounts/${encodeURIComponent(id)}`) +} + +/** + * Resolve `user` or `user@host` to an account. + * + * `/api/v1/accounts/lookup` is the fast path but predates Pleroma's + * compatibility work, so fall back to search with `resolve=1`, which also + * pulls in accounts the instance has never seen before. + */ +export async function lookupAccount(api: ApiClient, acct: string): Promise { + const handle = acct.replace(/^@/, '') + try { + return await api.get('/api/v1/accounts/lookup', { acct: handle }) + } catch (cause) { + if (!(cause instanceof ApiError) || cause.status === 0) throw cause + + const results = await api.get('/api/v2/search', { + q: handle, + type: 'accounts', + resolve: api.authenticated, + limit: 5, + }) + const exact = results.accounts.find((account) => account.acct.toLowerCase() === handle.toLowerCase()) + if (exact) return exact + if (results.accounts.length > 0) return results.accounts[0] + throw cause + } +} + +export function fetchFollowers(api: ApiClient, id: string, cursor: Cursor = {}): Promise> { + return api.page(`/api/v1/accounts/${encodeURIComponent(id)}/followers`, { ...cursor }) +} + +export function fetchFollowing(api: ApiClient, id: string, cursor: Cursor = {}): Promise> { + return api.page(`/api/v1/accounts/${encodeURIComponent(id)}/following`, { ...cursor }) +} + +export async function fetchRelationship(api: ApiClient, id: string): Promise { + if (!api.authenticated) return null + // Array values are serialized as `id[]=…`, which is what Mastodon expects. + const rows = await api.get('/api/v1/accounts/relationships', { id: [id] }) + return rows[0] ?? null +} + +export function followAccount(api: ApiClient, id: string): Promise { + return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/follow`) +} + +export function unfollowAccount(api: ApiClient, id: string): Promise { + return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/unfollow`) +} + +export function blockAccount(api: ApiClient, id: string): Promise { + return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/block`) +} + +export function unblockAccount(api: ApiClient, id: string): Promise { + return api.post(`/api/v1/accounts/${encodeURIComponent(id)}/unblock`) +} + +/** The instance's opt-in profile directory — the "Browse" page's source. */ +export function fetchDirectory( + api: ApiClient, + options: { offset?: number; limit?: number; order?: 'active' | 'new'; local?: boolean } = {}, +): Promise { + return api.get('/api/v1/directory', { + offset: options.offset ?? 0, + limit: options.limit ?? 20, + order: options.order ?? 'active', + local: options.local ?? true, + }) +} + +/* ------------------------------------------------------------ blog entries */ + +export type TimelineKind = 'home' | 'public' | 'local' | 'tag' + +export function fetchTimeline( + api: ApiClient, + kind: TimelineKind, + cursor: Cursor = {}, + options: { tag?: string } = {}, +): Promise> { + switch (kind) { + case 'home': + return api.page('/api/v1/timelines/home', { ...cursor }) + case 'local': + return api.page('/api/v1/timelines/public', { ...cursor, local: true }) + case 'tag': + return api.page(`/api/v1/timelines/tag/${encodeURIComponent(options.tag ?? '')}`, { ...cursor }) + case 'public': + default: + return api.page('/api/v1/timelines/public', { ...cursor }) + } +} + +export function fetchAccountStatuses( + api: ApiClient, + id: string, + cursor: Cursor = {}, + options: { exclude_replies?: boolean; exclude_reblogs?: boolean; only_media?: boolean; pinned?: boolean } = {}, +): Promise> { + return api.page(`/api/v1/accounts/${encodeURIComponent(id)}/statuses`, { ...cursor, ...options }) +} + +export function fetchStatus(api: ApiClient, id: string): Promise { + return api.get(`/api/v1/statuses/${encodeURIComponent(id)}`) +} + +export function fetchContext(api: ApiClient, id: string): Promise { + return api.get(`/api/v1/statuses/${encodeURIComponent(id)}/context`) +} + +export interface ComposeOptions { + status: string + in_reply_to_id?: string | null + visibility?: StatusVisibility + spoiler_text?: string + sensitive?: boolean + media_ids?: string[] + language?: string +} + +export function postStatus(api: ApiClient, options: ComposeOptions): Promise { + const body: Record = { status: options.status } + if (options.in_reply_to_id) body.in_reply_to_id = options.in_reply_to_id + if (options.visibility) body.visibility = options.visibility + if (options.spoiler_text) { + body.spoiler_text = options.spoiler_text + body.sensitive = true + } + if (options.sensitive) body.sensitive = true + if (options.media_ids?.length) body.media_ids = options.media_ids + if (options.language) body.language = options.language + return api.post('/api/v1/statuses', body) +} + +export function deleteStatus(api: ApiClient, id: string): Promise { + return api.delete(`/api/v1/statuses/${encodeURIComponent(id)}`) +} + +export function favouriteStatus(api: ApiClient, id: string, on: boolean): Promise { + const action = on ? 'favourite' : 'unfavourite' + return api.post(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`) +} + +export function reblogStatus(api: ApiClient, id: string, on: boolean): Promise { + const action = on ? 'reblog' : 'unreblog' + return api.post(`/api/v1/statuses/${encodeURIComponent(id)}/${action}`) +} + +export async function uploadMedia(api: ApiClient, file: File, description?: string): Promise { + const form = new FormData() + form.set('file', file) + if (description) form.set('description', description) + // v2 returns 202 while transcoding; v1 blocks until ready, which is simpler + // for a client with no job-polling loop. + const { data } = await api.raw('/api/v1/media', { method: 'POST', form }) + return data +} + +/* ------------------------------------------------------------- mail centre */ + +export function fetchNotifications( + api: ApiClient, + cursor: Cursor = {}, + types?: string[], +): Promise> { + const query: Query = { ...cursor } + if (types?.length) query.types = types + return api.page('/api/v1/notifications', query) +} + +export function fetchFollowRequests(api: ApiClient, cursor: Cursor = {}): Promise> { + return api.page('/api/v1/follow_requests', { ...cursor }) +} + +export function authorizeFollowRequest(api: ApiClient, id: string): Promise { + return api.post(`/api/v1/follow_requests/${encodeURIComponent(id)}/authorize`) +} + +export function rejectFollowRequest(api: ApiClient, id: string): Promise { + return api.post(`/api/v1/follow_requests/${encodeURIComponent(id)}/reject`) +} + +/* ----------------------------------------------------------------- search */ + +export function search( + api: ApiClient, + q: string, + options: { type?: 'accounts' | 'statuses' | 'hashtags'; limit?: number; offset?: number } = {}, +): Promise { + return api.get('/api/v2/search', { + q, + type: options.type, + limit: options.limit ?? 20, + offset: options.offset, + resolve: api.authenticated, + }) +} diff --git a/src/lib/api/oauth.ts b/src/lib/api/oauth.ts new file mode 100644 index 0000000..8a56f2e --- /dev/null +++ b/src/lib/api/oauth.ts @@ -0,0 +1,208 @@ +/** + * Browser-side OAuth 2.0 for Mastodon-compatible servers. + * + * A static frontend has no backend to keep a client secret, so it registers a + * throwaway app per instance (`POST /api/v1/apps`) and uses the authorization + * code flow, upgrading to PKCE when the server advertises support (Mastodon + * 4.3+). Pleroma and older Mastodon ignore the PKCE params, hence the retry + * without them rather than a hard requirement. + * + * The registered app credentials and the resulting token live in localStorage; + * they are per-origin and per-instance, and are exactly as sensitive as being + * logged in on this browser. + */ + +import { ApiClient, ApiError, normalizeHost } from './client' +import type { OAuthApp, OAuthToken } from './types' + +export const APP_NAME = 'plspace' +export const APP_WEBSITE = 'https://github.com/plspace' +export const SCOPES = 'read write follow' + +const APP_KEY = 'plspace:oauth:apps' +const PENDING_KEY = 'plspace:oauth:pending' + +interface PendingAuth { + host: string + verifier?: string + state: string + /** Route to land on after a successful exchange. */ + returnTo: string +} + +/** + * The redirect target must match what was registered byte-for-byte. The app + * uses hash routing, so the OAuth code comes back on the query string of the + * document URL and the hash stays free for our own router. + */ +export function redirectUri(): string { + return `${window.location.origin}${window.location.pathname}` +} + +function readApps(): Record { + try { + return JSON.parse(localStorage.getItem(APP_KEY) ?? '{}') as Record + } catch { + return {} + } +} + +function writeApp(host: string, app: OAuthApp): void { + const apps = readApps() + apps[host] = app + localStorage.setItem(APP_KEY, JSON.stringify(apps)) +} + +/** Register (or reuse) an OAuth app on `host`. */ +export async function ensureApp(host: string): Promise { + const key = normalizeHost(host) + const cached = readApps()[key] + // Re-register if the deployment moved: a stale redirect_uri fails at /oauth/authorize + // with an opaque error page, which is miserable to debug. + if (cached && cached.redirect_uri === redirectUri()) return cached + + const client = new ApiClient(key) + const app = await client.post('/api/v1/apps', { + client_name: APP_NAME, + redirect_uris: redirectUri(), + scopes: SCOPES, + website: APP_WEBSITE, + }) + writeApp(key, app) + return app +} + +function randomString(bytes = 48): string { + const buffer = new Uint8Array(bytes) + crypto.getRandomValues(buffer) + return base64url(buffer) +} + +function base64url(buffer: ArrayBuffer | Uint8Array): string { + const view = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer) + let binary = '' + for (const byte of view) binary += String.fromCharCode(byte) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function challengeFor(verifier: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + return base64url(digest) +} + +/** Kick off the redirect to the instance's consent screen. */ +export async function beginLogin(host: string, returnTo = '#/'): Promise { + const key = normalizeHost(host) + const app = await ensureApp(key) + + const state = randomString(16) + // crypto.subtle is unavailable on insecure origins; fall back to a plain + // authorization-code flow there rather than failing to log in at all. + const canPkce = Boolean(crypto.subtle) + const verifier = canPkce ? randomString(48) : undefined + + const pending: PendingAuth = { host: key, verifier, state, returnTo } + sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)) + + const params = new URLSearchParams({ + client_id: app.client_id, + redirect_uri: redirectUri(), + response_type: 'code', + scope: SCOPES, + state, + }) + if (verifier) { + params.set('code_challenge', await challengeFor(verifier)) + params.set('code_challenge_method', 'S256') + } + + window.location.assign(`https://${key}/oauth/authorize?${params.toString()}`) +} + +export interface CompletedLogin { + host: string + token: string + returnTo: string +} + +/** + * Complete the flow if the current URL carries an authorization code. + * Returns null when this is an ordinary page load. + */ +export async function completeLogin(): Promise { + const url = new URL(window.location.href) + const code = url.searchParams.get('code') + const error = url.searchParams.get('error') + const state = url.searchParams.get('state') + + if (!code && !error) return null + + const rawPending = sessionStorage.getItem(PENDING_KEY) + sessionStorage.removeItem(PENDING_KEY) + clearOAuthParams() + + if (error) { + throw new Error(url.searchParams.get('error_description') ?? `Authorization failed: ${error}`) + } + if (!rawPending) { + throw new Error('No sign-in was in progress in this tab. Please start again.') + } + + const pending = JSON.parse(rawPending) as PendingAuth + if (pending.state !== state) { + throw new Error('Sign-in state did not match. Please start again.') + } + + const app = readApps()[pending.host] + if (!app) throw new Error('Lost the app registration for this server. Please start again.') + + const client = new ApiClient(pending.host) + const body: Record = { + grant_type: 'authorization_code', + client_id: app.client_id, + client_secret: app.client_secret, + redirect_uri: redirectUri(), + scope: SCOPES, + code: code!, + } + if (pending.verifier) body.code_verifier = pending.verifier + + let token: OAuthToken + try { + token = await client.post('/oauth/token', body) + } catch (cause) { + // Servers that don't implement PKCE reject the unexpected code_verifier. + if (pending.verifier && cause instanceof ApiError && cause.status === 400) { + delete body.code_verifier + token = await client.post('/oauth/token', body) + } else { + throw cause + } + } + + return { host: pending.host, token: token.access_token, returnTo: pending.returnTo || '#/' } +} + +/** Strip `?code=…&state=…` so a refresh doesn't try to redeem a spent code. */ +function clearOAuthParams(): void { + const url = new URL(window.location.href) + for (const key of ['code', 'state', 'error', 'error_description', 'iss']) { + url.searchParams.delete(key) + } + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`) +} + +/** Best-effort token revocation; failure is not worth blocking sign-out. */ +export async function revoke(host: string, token: string): Promise { + const app = readApps()[normalizeHost(host)] + if (!app) return + try { + await new ApiClient(host).post('/oauth/revoke', { + client_id: app.client_id, + client_secret: app.client_secret, + token, + }) + } catch { + /* the local token is dropped regardless */ + } +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 0000000..602920e --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,297 @@ +/** + * Mastodon REST API entities (v1), typed conservatively. + * + * Everything here is written to survive Pleroma/Akkoma/GoToSocial/Iceshrimp as + * well as Mastodon proper, so anything that is not universally present is + * optional. Two rules of thumb learned the hard way: + * + * - Field presence differs per server *and* per authentication state. A + * logged-out `GET /api/v1/timelines/public` omits `favourited`/`reblogged` + * everywhere, and Pleroma omits several Mastodon-4.x additions entirely. + * - Counters are sometimes hidden (`-1` or `0`) rather than absent when a user + * opts out of showing collections, so never treat 0 as "definitely none". + */ + +export type StatusVisibility = 'public' | 'unlisted' | 'private' | 'direct' + +export interface CustomEmoji { + shortcode: string + url: string + static_url: string + visible_in_picker: boolean + category?: string | null +} + +export interface AccountField { + name: string + /** HTML */ + value: string + verified_at?: string | null +} + +export interface AccountRole { + id: string + name: string + color: string +} + +export interface Account { + id: string + username: string + /** `user` for local accounts, `user@host` for remote ones. */ + acct: string + display_name: string + /** HTML bio. */ + note: string + url: string + uri?: string + avatar: string + avatar_static: string + header: string + header_static: string + locked: boolean + bot?: boolean + group?: boolean + discoverable?: boolean | null + created_at: string + last_status_at?: string | null + statuses_count: number + followers_count: number + following_count: number + fields: AccountField[] + emojis: CustomEmoji[] + roles?: AccountRole[] + moved?: Account | null + suspended?: boolean + limited?: boolean + hide_collections?: boolean | null + + /** Pleroma/Akkoma extension bag. Present only on those servers. */ + pleroma?: { + background_image?: string | null + is_admin?: boolean + is_moderator?: boolean + hide_favorites?: boolean + /** Pleroma's equivalent of Mastodon's `hide_collections`. */ + hide_followers?: boolean + hide_follows?: boolean + /** + * When set, `followers_count` is reported as 0 rather than withheld — so a + * zero here means "not telling you", not "nobody". + */ + hide_followers_count?: boolean + hide_follows_count?: boolean + relationship?: Relationship + /** Some deployments expose the user's own profile CSS here. */ + background_color?: string | null + } +} + +export interface CredentialAccount extends Account { + source?: { + note: string + fields: AccountField[] + privacy: StatusVisibility + sensitive: boolean + language: string | null + follow_requests_count?: number + } +} + +export interface MediaAttachment { + id: string + type: 'unknown' | 'image' | 'gifv' | 'video' | 'audio' + url: string + preview_url: string | null + remote_url?: string | null + description?: string | null + blurhash?: string | null + meta?: { + original?: { width?: number; height?: number; aspect?: number } + small?: { width?: number; height?: number; aspect?: number } + [key: string]: unknown + } +} + +export interface StatusMention { + id: string + username: string + url: string + acct: string +} + +export interface StatusTag { + name: string + url: string +} + +export interface PreviewCard { + url: string + title: string + description: string + type: 'link' | 'photo' | 'video' | 'rich' + image?: string | null + provider_name?: string + author_name?: string +} + +export interface PollOption { + title: string + votes_count: number | null +} + +export interface Poll { + id: string + expires_at: string | null + expired: boolean + multiple: boolean + votes_count: number + voters_count?: number | null + options: PollOption[] + emojis: CustomEmoji[] + voted?: boolean + own_votes?: number[] +} + +export interface Status { + id: string + uri: string + created_at: string + account: Account + /** HTML. Must be sanitized before it goes anywhere near {@html}. */ + content: string + visibility: StatusVisibility + sensitive: boolean + spoiler_text: string + language?: string | null + url?: string | null + edited_at?: string | null + + in_reply_to_id: string | null + in_reply_to_account_id: string | null + + replies_count: number + reblogs_count: number + favourites_count: number + + media_attachments: MediaAttachment[] + mentions: StatusMention[] + tags: StatusTag[] + emojis: CustomEmoji[] + card?: PreviewCard | null + poll?: Poll | null + application?: { name: string; website?: string | null } | null + + reblog: Status | null + + favourited?: boolean + reblogged?: boolean + muted?: boolean + bookmarked?: boolean + pinned?: boolean + + pleroma?: { + local?: boolean + conversation_id?: number + content?: Record + spoiler_text?: Record + } +} + +export interface Relationship { + id: string + following: boolean + followed_by: boolean + requested: boolean + blocking: boolean + blocked_by?: boolean + muting: boolean + muting_notifications?: boolean + domain_blocking?: boolean + endorsed?: boolean + note?: string + showing_reblogs?: boolean + notifying?: boolean +} + +export type NotificationType = + | 'mention' + | 'status' + | 'reblog' + | 'follow' + | 'follow_request' + | 'favourite' + | 'poll' + | 'update' + | 'admin.sign_up' + | 'admin.report' + | 'pleroma:emoji_reaction' + | 'pleroma:report' + +export interface Notification { + id: string + type: NotificationType + created_at: string + account: Account + status?: Status | null +} + +export interface Context { + ancestors: Status[] + descendants: Status[] +} + +export interface InstanceInfo { + /** v1 shape */ + uri?: string + /** v2 shape */ + domain?: string + title: string + description?: string + short_description?: string + version: string + thumbnail?: string | { url: string } | null + /** v1 shape. */ + stats?: { + user_count: number + status_count: number + domain_count: number + } + /** v2 shape; only monthly actives are published. */ + usage?: { + users?: { active_month?: number } + } + configuration?: { + statuses?: { + max_characters?: number + max_media_attachments?: number + } + } + registrations?: boolean | { enabled?: boolean } + contact_account?: Account | null + /** Pleroma reports its "real" upstream here. */ + pleroma?: unknown +} + +export interface SearchResults { + accounts: Account[] + statuses: Status[] + hashtags: StatusTag[] +} + +export interface OAuthApp { + id: string + name: string + website?: string | null + redirect_uri: string + client_id: string + client_secret: string + vapid_key?: string +} + +export interface OAuthToken { + access_token: string + token_type: string + scope: string + created_at: number +} diff --git a/src/lib/router.svelte.ts b/src/lib/router.svelte.ts new file mode 100644 index 0000000..24aa4bd --- /dev/null +++ b/src/lib/router.svelte.ts @@ -0,0 +1,125 @@ +/** + * Hash router. + * + * Hash routing (rather than the History API) is what makes `dist/` a genuinely + * static bundle: it can be served from a subdirectory, an S3 bucket or a GitHub + * Pages path with no rewrite rules, and the OAuth redirect can land on the + * document URL's query string without colliding with our own routes. + */ + +export interface RouteMatch { + name: string + params: Record + query: URLSearchParams + /** The raw hash path, e.g. `/@alice@example.social/friends`. */ + path: string +} + +interface RoutePattern { + name: string + /** `/blog/:id` — `:param` captures one segment, `*rest` captures the remainder. */ + pattern: string +} + +const ROUTES: RoutePattern[] = [ + { name: 'home', pattern: '/' }, + { name: 'login', pattern: '/login' }, + { name: 'settings', pattern: '/settings' }, + { name: 'browse', pattern: '/browse' }, + { name: 'search', pattern: '/search' }, + { name: 'mail', pattern: '/mail' }, + { name: 'mail.folder', pattern: '/mail/:folder' }, + { name: 'timeline', pattern: '/timeline/:kind' }, + { name: 'tag', pattern: '/tag/:tag' }, + { name: 'blog.entry', pattern: '/blog/:id' }, + { name: 'compose', pattern: '/compose' }, + // Account routes come last: `:acct` is greedy enough to shadow the others. + { name: 'profile.friends', pattern: '/@:acct/friends' }, + { name: 'profile.blog', pattern: '/@:acct/blog' }, + { name: 'profile.pics', pattern: '/@:acct/pics' }, + { name: 'profile', pattern: '/@:acct' }, +] + +function matchPattern(pattern: string, path: string): Record | null { + const patternParts = pattern.split('/').filter(Boolean) + const pathParts = path.split('/').filter(Boolean) + + if (patternParts.length !== pathParts.length) return null + + const params: Record = {} + for (let index = 0; index < patternParts.length; index += 1) { + const expected = patternParts[index] + const actual = pathParts[index] + + // `@:acct` — a literal prefix followed by a capture, as in `/@alice@host`. + const prefixed = /^(@?)(:[a-zA-Z]+)$/.exec(expected) + if (prefixed) { + const [, prefix, name] = prefixed + if (prefix && !actual.startsWith(prefix)) return null + const value = decodeURIComponent(actual.slice(prefix.length)) + if (!value) return null + params[name.slice(1)] = value + continue + } + + if (expected !== actual) return null + } + + return params +} + +export function parseHash(hash: string): RouteMatch { + const raw = hash.replace(/^#/, '') || '/' + const [pathPart, queryPart = ''] = raw.split('?') + const path = pathPart || '/' + const query = new URLSearchParams(queryPart) + + for (const route of ROUTES) { + const params = matchPattern(route.pattern, path) + if (params) return { name: route.name, params, query, path } + } + + return { name: 'notfound', params: {}, query, path } +} + +class Router { + current = $state(parseHash(typeof location === 'undefined' ? '#/' : location.hash)) + + constructor() { + if (typeof window === 'undefined') return + window.addEventListener('hashchange', () => { + this.current = parseHash(location.hash) + // Matches the old-web expectation that a new "page" starts at the top. + window.scrollTo(0, 0) + }) + } + + /** Navigate, adding a history entry. */ + go(to: string): void { + const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}` + if (location.hash === target) { + this.current = parseHash(target) + return + } + location.hash = target + } + + /** Navigate without adding a history entry (search-as-you-type, tab switches). */ + replace(to: string): void { + const target = to.startsWith('#') ? to : `#${to.startsWith('/') ? to : `/${to}`}` + history.replaceState({}, '', target) + this.current = parseHash(target) + } +} + +export const router = new Router() + +/** Build a route string with an encoded query. */ +export function routeTo(path: string, query?: Record): string { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(query ?? {})) { + if (value) params.set(key, value) + } + const serialized = params.toString() + return `#${path}${serialized ? `?${serialized}` : ''}` +} diff --git a/src/lib/stores/feed.svelte.ts b/src/lib/stores/feed.svelte.ts new file mode 100644 index 0000000..ff52b87 --- /dev/null +++ b/src/lib/stores/feed.svelte.ts @@ -0,0 +1,121 @@ +/** + * Cursor-paginated list state. + * + * Every list in the app — timelines, friend lists, notifications, the directory + * — is the same shape: fetch a page, remember the cursor, append on demand. + * This wraps that with the two things that bite in practice: de-duplication + * (federated timelines repeat statuses across pages when new posts arrive + * mid-scroll) and out-of-order responses from an impatient "more" button. + */ + +import { ApiError, type Page } from '../api/client' +import type { Cursor } from '../api/endpoints' + +export interface Identified { + id: string +} + +export type Loader = (cursor: Cursor) => Promise> + +export class Feed { + items = $state([]) + loading = $state(false) + /** Distinguishes the initial spinner from the "more entries" spinner. */ + initialized = $state(false) + error = $state(null) + exhausted = $state(false) + + private loader: Loader + private pageSize: number + private nextCursor: string | undefined + private seen = new Set() + private generation = 0 + + constructor(loader: Loader, pageSize = 20) { + this.loader = loader + this.pageSize = pageSize + } + + /** Swap in a new loader and reload — used when a route param changes. */ + setLoader(loader: Loader): void { + this.loader = loader + void this.reload() + } + + async reload(): Promise { + this.generation += 1 + this.nextCursor = undefined + this.seen = new Set() + this.items = [] + this.exhausted = false + this.initialized = false + await this.run(this.generation, true) + } + + async loadMore(): Promise { + if (this.loading || this.exhausted) return + await this.run(this.generation, false) + } + + private async run(generation: number, replace: boolean): Promise { + this.loading = true + this.error = null + + try { + const page = await this.loader({ max_id: replace ? undefined : this.nextCursor, limit: this.pageSize }) + + // A newer reload started while this request was in flight; drop it. + if (generation !== this.generation) return + + const fresh = page.items.filter((item) => item && !this.seen.has(item.id)) + for (const item of fresh) this.seen.add(item.id) + + this.items = replace ? fresh : [...this.items, ...fresh] + + const previousCursor = this.nextCursor + this.nextCursor = page.links.maxId + + // Stop when the server runs out, or when it hands back the same cursor + // (some servers echo the cursor forever on an empty page). + if (page.items.length === 0 || !this.nextCursor || this.nextCursor === previousCursor) { + this.exhausted = true + } + } catch (cause) { + if (generation !== this.generation) return + this.error = + cause instanceof ApiError + ? cause.message + : cause instanceof Error + ? cause.message + : 'Something went wrong.' + this.exhausted = true + } finally { + if (generation === this.generation) { + this.loading = false + this.initialized = true + } + } + } + + /** Replace one item in place, e.g. after a favourite/repost toggle. */ + update(id: string, updater: (item: T) => T): void { + this.items = this.items.map((item) => (item.id === id ? updater(item) : item)) + } + + /** Drop an item, e.g. after deleting a post. */ + remove(id: string): void { + this.items = this.items.filter((item) => item.id !== id) + this.seen.delete(id) + } + + /** Insert at the top, e.g. after composing. */ + prepend(item: T): void { + if (this.seen.has(item.id)) return + this.seen.add(item.id) + this.items = [item, ...this.items] + } + + get isEmpty(): boolean { + return this.initialized && this.items.length === 0 && !this.error + } +} diff --git a/src/lib/stores/session.svelte.ts b/src/lib/stores/session.svelte.ts new file mode 100644 index 0000000..8734faa --- /dev/null +++ b/src/lib/stores/session.svelte.ts @@ -0,0 +1,153 @@ +/** + * The signed-in session: which server we're pointed at, the token, and the + * viewer's own account. + * + * Browsing logged-out is a first-class mode — you can point plspace at any + * public instance and read its local timeline — so `host` is meaningful even + * when `token` is null. + */ + +import { ApiClient, ApiError, normalizeHost } from '../api/client' +import { fetchInstance, verifyCredentials } from '../api/endpoints' +import * as oauth from '../api/oauth' +import type { CredentialAccount, InstanceInfo } from '../api/types' + +const STORAGE_KEY = 'plspace:session' + +interface PersistedSession { + host: string + token: string | null +} + +function load(): PersistedSession { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return { host: '', token: null } + const parsed = JSON.parse(raw) as PersistedSession + return { host: normalizeHost(parsed.host ?? ''), token: parsed.token ?? null } + } catch { + return { host: '', token: null } + } +} + +class Session { + host = $state('') + token = $state(null) + me = $state(null) + instance = $state(null) + + /** True until the first `restore()` settles, so routes can hold off. */ + loading = $state(true) + error = $state(null) + + /** A client bound to the current host and token. Recomputed on change. */ + readonly api = $derived(new ApiClient(this.host, this.token)) + + readonly signedIn = $derived(Boolean(this.token && this.me)) + readonly connected = $derived(Boolean(this.host)) + + /** + * Rehydrate from storage and finish any OAuth redirect. Called once at boot. + * Returns the route to land on, if the OAuth flow specified one. + */ + async restore(): Promise { + this.loading = true + this.error = null + let landing: string | null = null + + try { + const completed = await oauth.completeLogin() + if (completed) { + this.host = completed.host + this.token = completed.token + this.persist() + landing = completed.returnTo + } else { + const stored = load() + this.host = stored.host + this.token = stored.token + } + + if (!this.host) return landing + + // The instance description is cosmetic; never let it block sign-in. + void this.loadInstance() + + if (this.token) { + try { + this.me = await verifyCredentials(this.api) + } catch (cause) { + if (cause instanceof ApiError && cause.isAuthFailure) { + // Token revoked server-side, or the instance was reinstalled. + this.token = null + this.me = null + this.persist() + this.error = 'Your sign-in expired. Please log in again.' + } else { + throw cause + } + } + } + } catch (cause) { + this.error = cause instanceof Error ? cause.message : String(cause) + } finally { + this.loading = false + } + + return landing + } + + private async loadInstance(): Promise { + try { + this.instance = await fetchInstance(new ApiClient(this.host)) + } catch { + this.instance = null + } + } + + /** Point at a server without signing in. */ + async connect(host: string): Promise { + const normalized = normalizeHost(host) + if (!normalized) throw new Error('Enter a server address, for example pleroma.soykaf.com') + + // Probe before committing, so a typo surfaces here rather than on every page. + const probe = new ApiClient(normalized) + const instance = await fetchInstance(probe) + + this.host = normalized + this.token = null + this.me = null + this.instance = instance + this.error = null + this.persist() + } + + async login(host: string, returnTo = '#/'): Promise { + const normalized = normalizeHost(host) + if (!normalized) throw new Error('Enter a server address, for example pleroma.soykaf.com') + await oauth.beginLogin(normalized, returnTo) + } + + async logout(): Promise { + const { host, token } = this + this.token = null + this.me = null + this.persist() + if (host && token) await oauth.revoke(host, token) + } + + /** Forget the server entirely and return to the login screen. */ + disconnect(): void { + void this.logout() + this.host = '' + this.instance = null + localStorage.removeItem(STORAGE_KEY) + } + + private persist(): void { + const payload: PersistedSession = { host: this.host, token: this.token } + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) + } +} + +export const session = new Session() diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..5a3d507 --- /dev/null +++ b/src/lib/stores/theme.svelte.ts @@ -0,0 +1,207 @@ +/** + * User styling. + * + * The whole point of a MySpace profile was that you could wreck it with your + * own CSS. Two independent layers exist here: + * + * 1. **Viewer CSS** (`#user-stylesheet`) — what *you* set in Settings. Applies + * everywhere you browse and is stored locally. + * 2. **Profile CSS** (`#profile-stylesheet`) — what the *account being viewed* + * publishes, read from a profile field named `css` / `style` / `layout`. + * Cleared on navigation so it can never leak onto another page. + * + * Profile CSS is untrusted third-party input, so it is filtered: no `@import`, + * no `url()` pointing anywhere but https/data-images, no escaping the profile + * subtree. It is CSS only — there is no path here by which a remote profile can + * run script. + */ + +const VIEWER_STYLE_ID = 'user-stylesheet' +const PROFILE_STYLE_ID = 'profile-stylesheet' +const STORAGE_KEY = 'plspace:viewer-css' + +/** Root class the profile page carries; all profile CSS is confined to it. */ +export const PROFILE_SCOPE = '.profile-page' + +/** Field names checked, in order, for a profile's published stylesheet. */ +export const CSS_FIELD_NAMES = ['css', 'style', 'layout', 'stylesheet'] + +/** + * Get (or create) a style element, always moving it to the end of ``. + * + * The relocation is the important part. The app's own stylesheet is injected + * into `` when the bundle loads — after the `