replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

+1
.envrc
···
+
use flake
+3
.gitignore
···
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+
+
/result
+
/.direnv
+18
LICENSE
···
+
Copyright 2025 dusk <y.bera003.06@protonmail.com>
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
+
this software and associated documentation files (the โ€œSoftwareโ€), to deal in the
+
Software without restriction, including without limitation the rights to use, copy,
+
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
+
and to permit persons to whom the Software is furnished to do so, subject to the
+
following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+16 -33
README.md
···
-
# sv
+
## nucleus
-
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
+
a WIP replies timeline only (eg. it only shows replies to your posts and your own posts) appview-less (it does not use the bluesky appview, but rather uses [microcosm](https://www.microcosm.blue/) services) bluesky client. it is implemented in SvelteKit and uses [atcute](https://tangled.org/@mary.my.id/atcute).
-
## Creating a project
+
see it at [nucleus.ptr.pet](https://nucleus.ptr.pet).
-
If you're seeing this, you've probably already done this step. Congrats!
+
![screenshot](./resources/screenshot.png)
-
```sh
-
# create a new project in the current directory
-
npx sv create
+
### todos
-
# create a new project in my-app
-
npx sv create my-app
-
```
-
-
## Developing
-
-
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
-
-
```sh
-
npm run dev
-
-
# or start the server and open the app in a new browser tab
-
npm run dev -- --open
-
```
-
-
## Building
-
-
To create a production version of your app:
-
-
```sh
-
npm run build
-
```
-
-
You can preview the production build with `npm run preview`.
-
-
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+
- [x] properly implement auth (oauth)
+
- [ ] implement popouts for showing full chains instead of expanding in the timeline
+
- [ ] implement moderation (mutes, muted words etc., use blocks from `app.bsky.graph.block`)
+
- [ ] profile view popout
+
- [ ] consider showing posts that mention / quote the user..
+
- [ ] notifications when replied to (and mentioned and quoted?)
+
- [ ] basic filtering settings for the timeline (dont show self posts and if we implement mentioned / quoted add toggles for those as well)
+
- [ ] use bsky video CDN instead of linking to blob
+
- [ ] dont use listRecords to fetch own posts. we should have an index for this (red dwarf will have this so just piggyback off of that?)
+
- [ ] consider implementing feeds
+
- [ ] use jetstream to listen for own posts / likes / etc. made from other clients ? (pretty lowprio though, not sure if worth it, can just refresh)
+432 -310
deno.lock
···
{
"version": "5",
"specifiers": {
-
"npm:@atcute/atproto@^3.1.7": "3.1.7",
-
"npm:@atcute/bluesky@^3.2.7": "3.2.7",
+
"npm:@atcute/atproto@^3.1.8": "3.1.8",
+
"npm:@atcute/bluesky@^3.2.9": "3.2.9",
"npm:@atcute/client@^4.0.5": "4.0.5",
+
"npm:@atcute/identity-resolver@^1.1.4": "1.1.4_@atcute+identity@1.1.1",
"npm:@atcute/identity@^1.1.1": "1.1.1",
"npm:@atcute/lexicons@^1.2.2": "1.2.2",
-
"npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0",
-
"npm:@eslint/js@^9.36.0": "9.37.0",
-
"npm:@sveltejs/adapter-auto@^6.1.0": "6.1.1_@sveltejs+kit@2.47.0__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.40.1____acorn@8.15.0___vite@7.1.10____@types+node@24.8.0____picomatch@4.0.3___@types+node@24.8.0__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.8.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0",
-
"npm:@sveltejs/kit@^2.43.2": "2.47.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.8.0",
-
"npm:@sveltejs/vite-plugin-svelte@^6.2.0": "6.2.1_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0",
-
"npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.14",
-
"npm:@tailwindcss/vite@^4.1.13": "4.1.14_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0",
-
"npm:@types/node@24": "24.8.0",
-
"npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.37.0",
-
"npm:eslint-plugin-svelte@^3.12.4": "3.12.4_eslint@9.37.0_svelte@5.40.1__acorn@8.15.0_postcss@8.5.6",
-
"npm:eslint@^9.36.0": "9.37.0",
-
"npm:globals@^16.4.0": "16.4.0",
+
"npm:@atcute/oauth-browser-client@^2.0.1": "2.0.1_@atcute+identity@1.1.1",
+
"npm:@atcute/tid@^1.0.3": "1.0.3",
+
"npm:@eslint/compat@^1.4.1": "1.4.1_eslint@9.39.0",
+
"npm:@eslint/js@^9.39.0": "9.39.0",
+
"npm:@floating-ui/dom@^1.7.4": "1.7.4",
+
"npm:@iconify/svelte@^5.1.0": "5.1.0_svelte@5.43.2__acorn@8.15.0",
+
"npm:@soffinal/websocket@~0.2.1": "0.2.1_typescript@5.9.3",
+
"npm:@sveltejs/adapter-static@^3.0.10": "3.0.10_@sveltejs+kit@2.48.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.43.2____acorn@8.15.0___vite@7.1.12____@types+node@24.10.0____picomatch@4.0.3___@types+node@24.10.0__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.10.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0",
+
"npm:@sveltejs/kit@^2.48.4": "2.48.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.10.0",
+
"npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0",
+
"npm:@tailwindcss/forms@~0.5.10": "0.5.10_tailwindcss@4.1.16",
+
"npm:@tailwindcss/vite@^4.1.16": "4.1.16_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0",
+
"npm:@types/node@^24.10.0": "24.10.0",
+
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
+
"npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.0",
+
"npm:eslint-plugin-svelte@^3.13.0": "3.13.0_eslint@9.39.0_svelte@5.43.2__acorn@8.15.0_postcss@8.5.6",
+
"npm:eslint@^9.39.0": "9.39.0",
+
"npm:globals@^16.5.0": "16.5.0",
+
"npm:hash-wasm@^4.12.0": "4.12.0",
"npm:lru-cache@^11.2.2": "11.2.2",
-
"npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0",
-
"npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0",
+
"npm:prettier-plugin-svelte@^3.4.0": "3.4.0_prettier@3.6.2_svelte@5.43.2__acorn@8.15.0",
+
"npm:prettier-plugin-tailwindcss@~0.6.14": "0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.43.2___acorn@8.15.0_svelte@5.43.2__acorn@8.15.0",
"npm:prettier@^3.6.2": "3.6.2",
-
"npm:svelte-check@^4.3.2": "4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3",
-
"npm:svelte@^5.39.5": "5.40.1_acorn@8.15.0",
-
"npm:tailwindcss@^4.1.13": "4.1.14",
-
"npm:typescript-eslint@^8.44.1": "8.46.1_eslint@9.37.0_typescript@5.9.3_@typescript-eslint+parser@8.46.1__eslint@9.37.0__typescript@5.9.3",
-
"npm:typescript@^5.9.2": "5.9.3",
-
"npm:vite@^7.1.7": "7.1.10_@types+node@24.8.0_picomatch@4.0.3"
+
"npm:svelte-awesome-color-picker@^4.1.0": "4.1.0_svelte@5.43.2__acorn@8.15.0",
+
"npm:svelte-check@^4.3.3": "4.3.3_svelte@5.43.2__acorn@8.15.0_typescript@5.9.3",
+
"npm:svelte-device-info@^1.0.6": "1.0.6",
+
"npm:svelte-infinite@~0.5.1": "0.5.1_svelte@5.43.2__acorn@8.15.0",
+
"npm:svelte-portal@^2.2.1": "2.2.1",
+
"npm:svelte@^5.43.2": "5.43.2_acorn@8.15.0",
+
"npm:tailwindcss@^4.1.16": "4.1.16",
+
"npm:typescript-eslint@^8.46.3": "8.46.3_eslint@9.39.0_typescript@5.9.3_@typescript-eslint+parser@8.46.3__eslint@9.39.0__typescript@5.9.3",
+
"npm:typescript@^5.9.3": "5.9.3",
+
"npm:vite@^7.1.12": "7.1.12_@types+node@24.10.0_picomatch@4.0.3"
},
"npm": {
-
"@atcute/atproto@3.1.7": {
-
"integrity": "sha512-3Ym8qaVZg2vf8qw0KO1aue39z/5oik5J+UDoSes1vr8ddw40UVLA5sV4bXSKmLnhzQHiLLgoVZXe4zaKfozPoQ==",
+
"@atcute/atproto@3.1.8": {
+
"integrity": "sha512-Miu+S7RSgAYbmQWtHJKfSFUN5Kliqoo4YH0rILPmBtfmlZieORJgXNj9oO/Uive0/ulWkiRse07ATIcK8JxMnw==",
"dependencies": [
"@atcute/lexicons"
]
},
-
"@atcute/bluesky@3.2.7": {
-
"integrity": "sha512-mofkZySIIp+Z+TbBD+cDWaPY6FVKNRZG8yhMFkh6uMCuiazDUAUjxr4yaFjYMVcgMN9FkwGllwQJevUH9aTSnQ==",
+
"@atcute/bluesky@3.2.9": {
+
"integrity": "sha512-69+mAnnH/uyMoT3/jHLBNILHa3+dm8utDKbm/2xqSPMLvRK47Wo5COlpchu8Xq+NGisHqukhHYT8NYdQFfSJhA==",
"dependencies": [
"@atcute/atproto",
"@atcute/lexicons"
···
"@atcute/lexicons"
]
},
+
"@atcute/identity-resolver@1.1.4_@atcute+identity@1.1.1": {
+
"integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==",
+
"dependencies": [
+
"@atcute/identity",
+
"@atcute/lexicons",
+
"@atcute/util-fetch",
+
"@badrap/valita"
+
]
+
},
"@atcute/identity@1.1.1": {
"integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==",
"dependencies": [
···
"esm-env"
]
},
+
"@atcute/multibase@1.1.6": {
+
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
+
"dependencies": [
+
"@atcute/uint8array"
+
]
+
},
+
"@atcute/oauth-browser-client@2.0.1_@atcute+identity@1.1.1": {
+
"integrity": "sha512-lG021GkeORG06zfFf4bH85egObjBEKHNgAWHvbtY/E2dX4wxo88hf370pJDx8acdnuUJLJ2VKPikJtZwo4Heeg==",
+
"dependencies": [
+
"@atcute/client",
+
"@atcute/identity",
+
"@atcute/identity-resolver",
+
"@atcute/lexicons",
+
"@atcute/multibase",
+
"@atcute/uint8array",
+
"nanoid@5.1.6"
+
]
+
},
+
"@atcute/tid@1.0.3": {
+
"integrity": "sha512-wfMJx1IMdnu0CZgWl0uR4JO2s6PGT1YPhpytD4ZHzEYKKQVuqV6Eb/7vieaVo1eYNMp2FrY67FZObeR7utRl2w=="
+
},
+
"@atcute/uint8array@1.0.5": {
+
"integrity": "sha512-XLWWxoR2HNl2qU+FCr0rp1APwJXci7HnzbOQLxK55OaMNBXZ19+xNC5ii4QCsThsDxa4JS/JTzuiQLziITWf2Q=="
+
},
+
"@atcute/util-fetch@1.0.3": {
+
"integrity": "sha512-f8zzTb/xlKIwv2OQ31DhShPUNCmIIleX6p7qIXwWwEUjX6x8skUtpdISSjnImq01LXpltGV5y8yhV4/Mlb7CRQ==",
+
"dependencies": [
+
"@badrap/valita"
+
]
+
},
"@badrap/valita@0.4.6": {
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
},
-
"@esbuild/aix-ppc64@0.25.11": {
-
"integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
+
"@esbuild/aix-ppc64@0.25.12": {
+
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"os": ["aix"],
"cpu": ["ppc64"]
},
-
"@esbuild/android-arm64@0.25.11": {
-
"integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==",
+
"@esbuild/android-arm64@0.25.12": {
+
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"os": ["android"],
"cpu": ["arm64"]
},
-
"@esbuild/android-arm@0.25.11": {
-
"integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
+
"@esbuild/android-arm@0.25.12": {
+
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"os": ["android"],
"cpu": ["arm"]
},
-
"@esbuild/android-x64@0.25.11": {
-
"integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
+
"@esbuild/android-x64@0.25.12": {
+
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"os": ["android"],
"cpu": ["x64"]
},
-
"@esbuild/darwin-arm64@0.25.11": {
-
"integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
+
"@esbuild/darwin-arm64@0.25.12": {
+
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"@esbuild/darwin-x64@0.25.11": {
-
"integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==",
+
"@esbuild/darwin-x64@0.25.12": {
+
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"@esbuild/freebsd-arm64@0.25.11": {
-
"integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
+
"@esbuild/freebsd-arm64@0.25.12": {
+
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
-
"@esbuild/freebsd-x64@0.25.11": {
-
"integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==",
+
"@esbuild/freebsd-x64@0.25.12": {
+
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
-
"@esbuild/linux-arm64@0.25.11": {
-
"integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
+
"@esbuild/linux-arm64@0.25.12": {
+
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@esbuild/linux-arm@0.25.11": {
-
"integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
+
"@esbuild/linux-arm@0.25.12": {
+
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@esbuild/linux-ia32@0.25.11": {
-
"integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
+
"@esbuild/linux-ia32@0.25.12": {
+
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"os": ["linux"],
"cpu": ["ia32"]
},
-
"@esbuild/linux-loong64@0.25.11": {
-
"integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==",
+
"@esbuild/linux-loong64@0.25.12": {
+
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"os": ["linux"],
"cpu": ["loong64"]
},
-
"@esbuild/linux-mips64el@0.25.11": {
-
"integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
+
"@esbuild/linux-mips64el@0.25.12": {
+
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"os": ["linux"],
"cpu": ["mips64el"]
},
-
"@esbuild/linux-ppc64@0.25.11": {
-
"integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
+
"@esbuild/linux-ppc64@0.25.12": {
+
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"os": ["linux"],
"cpu": ["ppc64"]
},
-
"@esbuild/linux-riscv64@0.25.11": {
-
"integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
+
"@esbuild/linux-riscv64@0.25.12": {
+
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"os": ["linux"],
"cpu": ["riscv64"]
},
-
"@esbuild/linux-s390x@0.25.11": {
-
"integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
+
"@esbuild/linux-s390x@0.25.12": {
+
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"os": ["linux"],
"cpu": ["s390x"]
},
-
"@esbuild/linux-x64@0.25.11": {
-
"integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==",
+
"@esbuild/linux-x64@0.25.12": {
+
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@esbuild/netbsd-arm64@0.25.11": {
-
"integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
+
"@esbuild/netbsd-arm64@0.25.12": {
+
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
-
"@esbuild/netbsd-x64@0.25.11": {
-
"integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
+
"@esbuild/netbsd-x64@0.25.12": {
+
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"os": ["netbsd"],
"cpu": ["x64"]
},
-
"@esbuild/openbsd-arm64@0.25.11": {
-
"integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
+
"@esbuild/openbsd-arm64@0.25.12": {
+
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
-
"@esbuild/openbsd-x64@0.25.11": {
-
"integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==",
+
"@esbuild/openbsd-x64@0.25.12": {
+
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"os": ["openbsd"],
"cpu": ["x64"]
},
-
"@esbuild/openharmony-arm64@0.25.11": {
-
"integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
+
"@esbuild/openharmony-arm64@0.25.12": {
+
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
-
"@esbuild/sunos-x64@0.25.11": {
-
"integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
+
"@esbuild/sunos-x64@0.25.12": {
+
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"os": ["sunos"],
"cpu": ["x64"]
},
-
"@esbuild/win32-arm64@0.25.11": {
-
"integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==",
+
"@esbuild/win32-arm64@0.25.12": {
+
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"os": ["win32"],
"cpu": ["arm64"]
},
-
"@esbuild/win32-ia32@0.25.11": {
-
"integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
+
"@esbuild/win32-ia32@0.25.12": {
+
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
-
"@esbuild/win32-x64@0.25.11": {
-
"integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
+
"@esbuild/win32-x64@0.25.12": {
+
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"@eslint-community/eslint-utils@4.9.0_eslint@9.37.0": {
+
"@eslint-community/eslint-utils@4.9.0_eslint@9.39.0": {
"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
"dependencies": [
"eslint",
"eslint-visitor-keys@3.4.3"
]
},
-
"@eslint-community/regexpp@4.12.1": {
-
"integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="
+
"@eslint-community/regexpp@4.12.2": {
+
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="
},
-
"@eslint/compat@1.4.0_eslint@9.37.0": {
-
"integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==",
+
"@eslint/compat@1.4.1_eslint@9.39.0": {
+
"integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==",
"dependencies": [
"@eslint/core",
"eslint"
···
"eslint"
]
},
-
"@eslint/config-array@0.21.0": {
-
"integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==",
+
"@eslint/config-array@0.21.1": {
+
"integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==",
"dependencies": [
"@eslint/object-schema",
"debug",
"minimatch@3.1.2"
]
},
-
"@eslint/config-helpers@0.4.0": {
-
"integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==",
+
"@eslint/config-helpers@0.4.2": {
+
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dependencies": [
"@eslint/core"
]
},
-
"@eslint/core@0.16.0": {
-
"integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==",
+
"@eslint/core@0.17.0": {
+
"integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
"dependencies": [
"@types/json-schema"
]
···
"strip-json-comments"
]
},
-
"@eslint/js@9.37.0": {
-
"integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="
+
"@eslint/js@9.39.0": {
+
"integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw=="
},
-
"@eslint/object-schema@2.1.6": {
-
"integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="
+
"@eslint/object-schema@2.1.7": {
+
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="
},
-
"@eslint/plugin-kit@0.4.0": {
-
"integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==",
+
"@eslint/plugin-kit@0.4.1": {
+
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dependencies": [
"@eslint/core",
"levn"
]
},
+
"@floating-ui/core@1.7.3": {
+
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+
"dependencies": [
+
"@floating-ui/utils"
+
]
+
},
+
"@floating-ui/dom@1.7.4": {
+
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+
"dependencies": [
+
"@floating-ui/core",
+
"@floating-ui/utils"
+
]
+
},
+
"@floating-ui/utils@0.2.10": {
+
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="
+
},
"@humanfs/core@0.19.1": {
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="
},
···
"@humanwhocodes/retry@0.4.3": {
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
},
-
"@isaacs/fs-minipass@4.0.1": {
-
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+
"@iconify/svelte@5.1.0_svelte@5.43.2__acorn@8.15.0": {
+
"integrity": "sha512-I14nSqo0pNXO5OKsT61ZO3XIPF4yRHA2ErgPsaZ1sPJdKXn80o7o8jOe1xpWphbb9FihdX6by9zlKKBss61mFw==",
"dependencies": [
-
"minipass"
+
"@iconify/types",
+
"svelte"
]
+
},
+
"@iconify/types@2.0.0": {
+
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="
},
"@jridgewell/gen-mapping@0.3.13": {
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
···
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
-
"@rollup/rollup-android-arm-eabi@4.52.4": {
-
"integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==",
+
"@rollup/rollup-android-arm-eabi@4.52.5": {
+
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
"os": ["android"],
"cpu": ["arm"]
},
-
"@rollup/rollup-android-arm64@4.52.4": {
-
"integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==",
+
"@rollup/rollup-android-arm64@4.52.5": {
+
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
"os": ["android"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-darwin-arm64@4.52.4": {
-
"integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==",
+
"@rollup/rollup-darwin-arm64@4.52.5": {
+
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-darwin-x64@4.52.4": {
-
"integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==",
+
"@rollup/rollup-darwin-x64@4.52.5": {
+
"integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"@rollup/rollup-freebsd-arm64@4.52.4": {
-
"integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==",
+
"@rollup/rollup-freebsd-arm64@4.52.5": {
+
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-freebsd-x64@4.52.4": {
-
"integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==",
+
"@rollup/rollup-freebsd-x64@4.52.5": {
+
"integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
-
"@rollup/rollup-linux-arm-gnueabihf@4.52.4": {
-
"integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==",
+
"@rollup/rollup-linux-arm-gnueabihf@4.52.5": {
+
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@rollup/rollup-linux-arm-musleabihf@4.52.4": {
-
"integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==",
+
"@rollup/rollup-linux-arm-musleabihf@4.52.5": {
+
"integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@rollup/rollup-linux-arm64-gnu@4.52.4": {
-
"integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==",
+
"@rollup/rollup-linux-arm64-gnu@4.52.5": {
+
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-linux-arm64-musl@4.52.4": {
-
"integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==",
+
"@rollup/rollup-linux-arm64-musl@4.52.5": {
+
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-linux-loong64-gnu@4.52.4": {
-
"integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==",
+
"@rollup/rollup-linux-loong64-gnu@4.52.5": {
+
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
"os": ["linux"],
"cpu": ["loong64"]
},
-
"@rollup/rollup-linux-ppc64-gnu@4.52.4": {
-
"integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==",
+
"@rollup/rollup-linux-ppc64-gnu@4.52.5": {
+
"integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
"os": ["linux"],
"cpu": ["ppc64"]
},
-
"@rollup/rollup-linux-riscv64-gnu@4.52.4": {
-
"integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==",
+
"@rollup/rollup-linux-riscv64-gnu@4.52.5": {
+
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
"os": ["linux"],
"cpu": ["riscv64"]
},
-
"@rollup/rollup-linux-riscv64-musl@4.52.4": {
-
"integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==",
+
"@rollup/rollup-linux-riscv64-musl@4.52.5": {
+
"integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
"os": ["linux"],
"cpu": ["riscv64"]
},
-
"@rollup/rollup-linux-s390x-gnu@4.52.4": {
-
"integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==",
+
"@rollup/rollup-linux-s390x-gnu@4.52.5": {
+
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
"os": ["linux"],
"cpu": ["s390x"]
},
-
"@rollup/rollup-linux-x64-gnu@4.52.4": {
-
"integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==",
+
"@rollup/rollup-linux-x64-gnu@4.52.5": {
+
"integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@rollup/rollup-linux-x64-musl@4.52.4": {
-
"integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==",
+
"@rollup/rollup-linux-x64-musl@4.52.5": {
+
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@rollup/rollup-openharmony-arm64@4.52.4": {
-
"integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==",
+
"@rollup/rollup-openharmony-arm64@4.52.5": {
+
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-win32-arm64-msvc@4.52.4": {
-
"integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==",
+
"@rollup/rollup-win32-arm64-msvc@4.52.5": {
+
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
"os": ["win32"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-win32-ia32-msvc@4.52.4": {
-
"integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==",
+
"@rollup/rollup-win32-ia32-msvc@4.52.5": {
+
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
"os": ["win32"],
"cpu": ["ia32"]
},
-
"@rollup/rollup-win32-x64-gnu@4.52.4": {
-
"integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==",
+
"@rollup/rollup-win32-x64-gnu@4.52.5": {
+
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"@rollup/rollup-win32-x64-msvc@4.52.4": {
-
"integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==",
+
"@rollup/rollup-win32-x64-msvc@4.52.5": {
+
"integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
"os": ["win32"],
"cpu": ["x64"]
},
+
"@soffinal/stream@0.2.3_typescript@5.9.3": {
+
"integrity": "sha512-B0xWaDsVa6/HxttZmKqD7BmsveQQzuEoY9wztwGIuLF+nsVW1DW2V0kOJZIwTxp1wP4iKPalje1uZaZ+cYv7fg==",
+
"dependencies": [
+
"typescript"
+
]
+
},
+
"@soffinal/websocket@0.2.1_typescript@5.9.3": {
+
"integrity": "sha512-OvBZCtWLRT3gZpseHdd7qBsKNTVYnZsMUwk1aF5m/hZ632MOhaumi4WS/D/hasTHYQFh1XZXy7To+rMVWwubCw==",
+
"dependencies": [
+
"@soffinal/stream",
+
"typescript"
+
]
+
},
"@standard-schema/spec@1.0.0": {
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
},
···
"acorn"
]
},
-
"@sveltejs/adapter-auto@6.1.1_@sveltejs+kit@2.47.0__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.40.1____acorn@8.15.0___vite@7.1.10____@types+node@24.8.0____picomatch@4.0.3___@types+node@24.8.0__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.8.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0": {
-
"integrity": "sha512-cBNt4jgH4KuaNO5gRSB2CZKkGtz+OCZ8lPjRQGjhvVUD4akotnj2weUia6imLl2v07K3IgsQRyM36909miSwoQ==",
+
"@sveltejs/adapter-static@3.0.10_@sveltejs+kit@2.48.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.43.2____acorn@8.15.0___vite@7.1.12____@types+node@24.10.0____picomatch@4.0.3___@types+node@24.10.0__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__acorn@8.15.0__@types+node@24.10.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0": {
+
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dependencies": [
"@sveltejs/kit"
]
},
-
"@sveltejs/kit@2.47.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.8.0": {
-
"integrity": "sha512-mznN01MBXtr4T7X/E3ENkhF6GzqxTxL6/whG3OzCzUu8G8KYRNiCdoxLMVWAHJx/mDMPP3XAeKCMZHF/Xd/CDw==",
+
"@sveltejs/kit@2.48.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.10.0": {
+
"integrity": "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g==",
"dependencies": [
"@standard-schema/spec",
"@sveltejs/acorn-typescript",
···
],
"bin": true
},
-
"@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.40.1___acorn@8.15.0__vite@7.1.10___@types+node@24.8.0___picomatch@4.0.3__@types+node@24.8.0_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0": {
+
"@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.43.2___acorn@8.15.0__vite@7.1.12___@types+node@24.10.0___picomatch@4.0.3__@types+node@24.10.0_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0": {
"integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
···
"vite"
]
},
-
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.40.1__acorn@8.15.0_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0": {
+
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.43.2__acorn@8.15.0_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0": {
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
···
"vitefu"
]
},
-
"@tailwindcss/forms@0.5.10_tailwindcss@4.1.14": {
+
"@tailwindcss/forms@0.5.10_tailwindcss@4.1.16": {
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"dependencies": [
"mini-svg-data-uri",
"tailwindcss"
]
},
-
"@tailwindcss/node@4.1.14": {
-
"integrity": "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==",
+
"@tailwindcss/node@4.1.16": {
+
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"dependencies": [
"@jridgewell/remapping",
"enhanced-resolve",
···
"tailwindcss"
]
},
-
"@tailwindcss/oxide-android-arm64@4.1.14": {
-
"integrity": "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==",
+
"@tailwindcss/oxide-android-arm64@4.1.16": {
+
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"os": ["android"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-darwin-arm64@4.1.14": {
-
"integrity": "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==",
+
"@tailwindcss/oxide-darwin-arm64@4.1.16": {
+
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-darwin-x64@4.1.14": {
-
"integrity": "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==",
+
"@tailwindcss/oxide-darwin-x64@4.1.16": {
+
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide-freebsd-x64@4.1.14": {
-
"integrity": "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==",
+
"@tailwindcss/oxide-freebsd-x64@4.1.16": {
+
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"os": ["freebsd"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14": {
-
"integrity": "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==",
+
"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16": {
+
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@tailwindcss/oxide-linux-arm64-gnu@4.1.14": {
-
"integrity": "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==",
+
"@tailwindcss/oxide-linux-arm64-gnu@4.1.16": {
+
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-linux-arm64-musl@4.1.14": {
-
"integrity": "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==",
+
"@tailwindcss/oxide-linux-arm64-musl@4.1.16": {
+
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-linux-x64-gnu@4.1.14": {
-
"integrity": "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==",
+
"@tailwindcss/oxide-linux-x64-gnu@4.1.16": {
+
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide-linux-x64-musl@4.1.14": {
-
"integrity": "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==",
+
"@tailwindcss/oxide-linux-x64-musl@4.1.16": {
+
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide-wasm32-wasi@4.1.14": {
-
"integrity": "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==",
+
"@tailwindcss/oxide-wasm32-wasi@4.1.16": {
+
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"cpu": ["wasm32"]
},
-
"@tailwindcss/oxide-win32-arm64-msvc@4.1.14": {
-
"integrity": "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==",
+
"@tailwindcss/oxide-win32-arm64-msvc@4.1.16": {
+
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"os": ["win32"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-win32-x64-msvc@4.1.14": {
-
"integrity": "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==",
+
"@tailwindcss/oxide-win32-x64-msvc@4.1.16": {
+
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide@4.1.14": {
-
"integrity": "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==",
-
"dependencies": [
-
"detect-libc",
-
"tar"
-
],
+
"@tailwindcss/oxide@4.1.16": {
+
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"optionalDependencies": [
"@tailwindcss/oxide-android-arm64",
"@tailwindcss/oxide-darwin-arm64",
···
"@tailwindcss/oxide-wasm32-wasi",
"@tailwindcss/oxide-win32-arm64-msvc",
"@tailwindcss/oxide-win32-x64-msvc"
-
],
-
"scripts": true
+
]
},
-
"@tailwindcss/vite@4.1.14_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0": {
-
"integrity": "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==",
+
"@tailwindcss/vite@4.1.16_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0": {
+
"integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==",
"dependencies": [
"@tailwindcss/node",
"@tailwindcss/oxide",
···
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
-
"@types/node@24.8.0": {
-
"integrity": "sha512-5x08bUtU8hfboMTrJ7mEO4CpepS9yBwAqcL52y86SWNmbPX8LVbNs3EP4cNrIZgdjk2NAlP2ahNihozpoZIxSg==",
+
"@types/node@24.10.0": {
+
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dependencies": [
"undici-types"
]
},
-
"@typescript-eslint/eslint-plugin@8.46.1_@typescript-eslint+parser@8.46.1__eslint@9.37.0__typescript@5.9.3_eslint@9.37.0_typescript@5.9.3": {
-
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
+
"@typescript-eslint/eslint-plugin@8.46.3_@typescript-eslint+parser@8.46.3__eslint@9.39.0__typescript@5.9.3_eslint@9.39.0_typescript@5.9.3": {
+
"integrity": "sha512-sbaQ27XBUopBkRiuY/P9sWGOWUW4rl8fDoHIUmLpZd8uldsTyB4/Zg6bWTegPoTLnKj9Hqgn3QD6cjPNB32Odw==",
"dependencies": [
"@eslint-community/regexpp",
"@typescript-eslint/parser",
···
"typescript"
]
},
-
"@typescript-eslint/parser@8.46.1_eslint@9.37.0_typescript@5.9.3": {
-
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
+
"@typescript-eslint/parser@8.46.3_eslint@9.39.0_typescript@5.9.3": {
+
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dependencies": [
"@typescript-eslint/scope-manager",
"@typescript-eslint/types",
···
"typescript"
]
},
-
"@typescript-eslint/project-service@8.46.1_typescript@5.9.3": {
-
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
+
"@typescript-eslint/project-service@8.46.3_typescript@5.9.3": {
+
"integrity": "sha512-Fz8yFXsp2wDFeUElO88S9n4w1I4CWDTXDqDr9gYvZgUpwXQqmZBr9+NTTql5R3J7+hrJZPdpiWaB9VNhAKYLuQ==",
"dependencies": [
"@typescript-eslint/tsconfig-utils",
"@typescript-eslint/types",
···
"typescript"
]
},
-
"@typescript-eslint/scope-manager@8.46.1": {
-
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
+
"@typescript-eslint/scope-manager@8.46.3": {
+
"integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys"
]
},
-
"@typescript-eslint/tsconfig-utils@8.46.1_typescript@5.9.3": {
-
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
+
"@typescript-eslint/tsconfig-utils@8.46.3_typescript@5.9.3": {
+
"integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==",
"dependencies": [
"typescript"
]
},
-
"@typescript-eslint/type-utils@8.46.1_eslint@9.37.0_typescript@5.9.3": {
-
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
+
"@typescript-eslint/type-utils@8.46.3_eslint@9.39.0_typescript@5.9.3": {
+
"integrity": "sha512-ZPCADbr+qfz3aiTTYNNkCbUt+cjNwI/5McyANNrFBpVxPt7GqpEYz5ZfdwuFyGUnJ9FdDXbGODUu6iRCI6XRXw==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/typescript-estree",
···
"typescript"
]
},
-
"@typescript-eslint/types@8.46.1": {
-
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ=="
+
"@typescript-eslint/types@8.46.3": {
+
"integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA=="
},
-
"@typescript-eslint/typescript-estree@8.46.1_typescript@5.9.3": {
-
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
+
"@typescript-eslint/typescript-estree@8.46.3_typescript@5.9.3": {
+
"integrity": "sha512-f/NvtRjOm80BtNM5OQtlaBdM5BRFUv7gf381j9wygDNL+qOYSNOgtQ/DCndiYi80iIOv76QqaTmp4fa9hwI0OA==",
"dependencies": [
"@typescript-eslint/project-service",
"@typescript-eslint/tsconfig-utils",
···
"typescript"
]
},
-
"@typescript-eslint/utils@8.46.1_eslint@9.37.0_typescript@5.9.3": {
-
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
+
"@typescript-eslint/utils@8.46.3_eslint@9.39.0_typescript@5.9.3": {
+
"integrity": "sha512-VXw7qmdkucEx9WkmR3ld/u6VhRyKeiF1uxWwCy/iuNfokjJ7VhsgLSOTjsol8BunSw190zABzpwdNsze2Kpo4g==",
"dependencies": [
"@eslint-community/eslint-utils",
"@typescript-eslint/scope-manager",
···
"typescript"
]
},
-
"@typescript-eslint/visitor-keys@8.46.1": {
-
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
+
"@typescript-eslint/visitor-keys@8.46.3": {
+
"integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==",
"dependencies": [
"@typescript-eslint/types",
"eslint-visitor-keys@4.2.1"
+
]
+
},
+
"@wora/cache-persist@2.2.1": {
+
"integrity": "sha512-X9MHiML5F8z3mabnl6J8hAwjn9g6Sria6+wUGwo97UDLMOWpZtJ+Jp/DQ7GjI1JirVXMQUDXBftVDgpvjhpNcw==",
+
"dependencies": [
+
"idb"
]
},
"acorn-jsx@5.3.2_acorn@8.15.0": {
···
"readdirp"
]
},
-
"chownr@3.0.0": {
-
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="
-
},
"clsx@2.1.1": {
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
},
···
},
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
+
},
+
"colord@2.9.3": {
+
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
},
"concat-map@0.0.1": {
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
···
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
-
"devalue@5.4.1": {
-
"integrity": "sha512-YtoaOfsqjbZQKGIMRYDWKjUmSB4VJ/RElB+bXZawQAQYAo4xu08GKTMVlsZDTF6R2MbAgjcAQRPI5eIyRAT2OQ=="
+
"devalue@5.4.2": {
+
"integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw=="
},
"enhanced-resolve@5.18.3": {
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
···
"tapable"
]
},
-
"esbuild@0.25.11": {
-
"integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==",
+
"esbuild@0.25.12": {
+
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
···
"escape-string-regexp@4.0.0": {
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
-
"eslint-config-prettier@10.1.8_eslint@9.37.0": {
+
"eslint-config-prettier@10.1.8_eslint@9.39.0": {
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dependencies": [
"eslint"
],
"bin": true
},
-
"eslint-plugin-svelte@3.12.4_eslint@9.37.0_svelte@5.40.1__acorn@8.15.0_postcss@8.5.6": {
-
"integrity": "sha512-hD7wPe+vrPgx3U2X2b/wyTMtWobm660PygMGKrWWYTc9lvtY8DpNFDaU2CJQn1szLjGbn/aJ3g8WiXuKakrEkw==",
+
"eslint-plugin-svelte@3.13.0_eslint@9.39.0_svelte@5.43.2__acorn@8.15.0_postcss@8.5.6": {
+
"integrity": "sha512-2ohCCQJJTNbIpQCSDSTWj+FN0OVfPmSO03lmSNT7ytqMaWF6kpT86LdzDqtm4sh7TVPl/OEWJ/d7R87bXP2Vjg==",
"dependencies": [
"@eslint-community/eslint-utils",
"@jridgewell/sourcemap-codec",
"eslint",
"esutils",
-
"globals@16.4.0",
+
"globals@16.5.0",
"known-css-properties",
"postcss",
"postcss-load-config",
···
"eslint-visitor-keys@4.2.1": {
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="
},
-
"eslint@9.37.0": {
-
"integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==",
+
"eslint@9.39.0": {
+
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dependencies": [
"@eslint-community/eslint-utils",
"@eslint-community/regexpp",
···
"@humanwhocodes/module-importer",
"@humanwhocodes/retry",
"@types/estree",
-
"@types/json-schema",
"ajv",
"chalk",
"cross-spawn",
···
"estraverse"
]
},
-
"esrap@2.1.0": {
-
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
+
"esrap@2.1.2": {
+
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
···
"globals@14.0.0": {
"integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="
},
-
"globals@16.4.0": {
-
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="
+
"globals@16.5.0": {
+
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="
},
"graceful-fs@4.2.11": {
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
···
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
+
"hash-wasm@4.12.0": {
+
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ=="
+
},
+
"idb@4.0.5": {
+
"integrity": "sha512-P+Fk9HT2h1DhXoE1YNK183SY+CRh2GHNh28de94sGwhe0bUA75JJeVJWt3SenE5p0BXK7maflIq29dl6UZHrFw=="
+
},
"ignore@5.3.2": {
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="
},
···
"type-check"
},
-
"lightningcss-darwin-arm64@1.30.1": {
-
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
+
"lightningcss-android-arm64@1.30.2": {
+
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
+
"os": ["android"],
+
"cpu": ["arm64"]
+
},
+
"lightningcss-darwin-arm64@1.30.2": {
+
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"lightningcss-darwin-x64@1.30.1": {
-
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
+
"lightningcss-darwin-x64@1.30.2": {
+
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"lightningcss-freebsd-x64@1.30.1": {
-
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
+
"lightningcss-freebsd-x64@1.30.2": {
+
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"os": ["freebsd"],
"cpu": ["x64"]
},
-
"lightningcss-linux-arm-gnueabihf@1.30.1": {
-
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
+
"lightningcss-linux-arm-gnueabihf@1.30.2": {
+
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"lightningcss-linux-arm64-gnu@1.30.1": {
-
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
+
"lightningcss-linux-arm64-gnu@1.30.2": {
+
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"lightningcss-linux-arm64-musl@1.30.1": {
-
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
+
"lightningcss-linux-arm64-musl@1.30.2": {
+
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"lightningcss-linux-x64-gnu@1.30.1": {
-
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
+
"lightningcss-linux-x64-gnu@1.30.2": {
+
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"lightningcss-linux-x64-musl@1.30.1": {
-
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
+
"lightningcss-linux-x64-musl@1.30.2": {
+
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"lightningcss-win32-arm64-msvc@1.30.1": {
-
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
+
"lightningcss-win32-arm64-msvc@1.30.2": {
+
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"os": ["win32"],
"cpu": ["arm64"]
},
-
"lightningcss-win32-x64-msvc@1.30.1": {
-
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
+
"lightningcss-win32-x64-msvc@1.30.2": {
+
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"lightningcss@1.30.1": {
-
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
+
"lightningcss@1.30.2": {
+
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dependencies": [
"detect-libc"
],
"optionalDependencies": [
+
"lightningcss-android-arm64",
"lightningcss-darwin-arm64",
"lightningcss-darwin-x64",
"lightningcss-freebsd-x64",
···
"lru-cache@11.2.2": {
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="
},
-
"magic-string@0.30.19": {
-
"integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==",
+
"magic-string@0.30.21": {
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": [
"@jridgewell/sourcemap-codec"
···
"brace-expansion@2.0.2"
},
-
"minipass@7.1.2": {
-
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="
-
},
-
"minizlib@3.1.0": {
-
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
-
"dependencies": [
-
"minipass"
-
]
-
},
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
···
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"bin": true
},
+
"nanoid@5.1.6": {
+
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
+
"bin": true
+
},
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
···
"postcss@8.5.6": {
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dependencies": [
-
"nanoid",
+
"nanoid@3.3.11",
"picocolors",
"source-map-js"
···
"prelude-ls@1.2.1": {
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
},
-
"prettier-plugin-svelte@3.4.0_prettier@3.6.2_svelte@5.40.1__acorn@8.15.0": {
+
"prettier-plugin-svelte@3.4.0_prettier@3.6.2_svelte@5.43.2__acorn@8.15.0": {
"integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==",
"dependencies": [
"prettier",
"svelte"
},
-
"prettier-plugin-tailwindcss@0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.40.1___acorn@8.15.0_svelte@5.40.1__acorn@8.15.0": {
+
"prettier-plugin-tailwindcss@0.6.14_prettier@3.6.2_prettier-plugin-svelte@3.4.0__prettier@3.6.2__svelte@5.43.2___acorn@8.15.0_svelte@5.43.2__acorn@8.15.0": {
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dependencies": [
"prettier",
···
"reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
},
-
"rollup@4.52.4": {
-
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
+
"rollup@4.52.5": {
+
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"dependencies": [
"@types/estree"
],
···
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"bin": true
},
-
"set-cookie-parser@2.7.1": {
-
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="
+
"set-cookie-parser@2.7.2": {
+
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
},
"shebang-command@2.0.0": {
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
···
"has-flag"
},
-
"svelte-check@4.3.3_svelte@5.40.1__acorn@8.15.0_typescript@5.9.3": {
+
"svelte-awesome-color-picker@4.1.0_svelte@5.43.2__acorn@8.15.0": {
+
"integrity": "sha512-afiSB3eTBlqu96f4+rjBvqG3eCaLwuneNYHe587Wr4Ien6yQWeztGZunPT0FmiI7wFFBVNUlJQLYutII8LfQUg==",
+
"dependencies": [
+
"colord",
+
"svelte",
+
"svelte-awesome-slider"
+
]
+
},
+
"svelte-awesome-slider@2.0.0_svelte@5.43.2__acorn@8.15.0": {
+
"integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==",
+
"dependencies": [
+
"svelte"
+
]
+
},
+
"svelte-check@4.3.3_svelte@5.43.2__acorn@8.15.0_typescript@5.9.3": {
"integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==",
"dependencies": [
"@jridgewell/trace-mapping",
···
],
"bin": true
},
-
"svelte-eslint-parser@1.3.3_svelte@5.40.1__acorn@8.15.0_postcss@8.5.6": {
-
"integrity": "sha512-oTrDR8Z7Wnguut7QH3YKh7JR19xv1seB/bz4dxU5J/86eJtZOU4eh0/jZq4dy6tAlz/KROxnkRQspv5ZEt7t+Q==",
+
"svelte-device-info@1.0.6": {
+
"integrity": "sha512-G13YYkxnlz5AryOps8KFHFt8+5Ne7JiZgTxtYEXLVBF4UAwu9I1F+Xcd9rfhTZqUUtF9fm4qJpSi3I6p1JUt6Q==",
+
"dependencies": [
+
"tslib"
+
]
+
},
+
"svelte-eslint-parser@1.4.0_svelte@5.43.2__acorn@8.15.0_postcss@8.5.6": {
+
"integrity": "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA==",
"dependencies": [
"eslint-scope",
"eslint-visitor-keys@4.2.1",
···
"svelte"
},
-
"svelte@5.40.1_acorn@8.15.0": {
-
"integrity": "sha512-0R3t2oiLxJNJb2buz61MNfPdkjeyj2qTCM7TtIv/4ZfF12zD7Ig8iIo+C8febroy+9S4QJ7qfijtearSdO/1ww==",
+
"svelte-infinite@0.5.1_svelte@5.43.2__acorn@8.15.0": {
+
"integrity": "sha512-NvpYWrHPcLHZQMnqUXgKGpOSMq9kMQ6sa8+WO80jLrgBFX+LWoKvAsrc1d1g+eiaagNAE9HalWWJ4KDtYi/+sw==",
+
"dependencies": [
+
"svelte"
+
]
+
},
+
"svelte-portal@2.2.1": {
+
"integrity": "sha512-uF7is5sM4aq5iN7QF/67XLnTUvQCf2iiG/B1BHTqLwYVY1dsVmTeXZ/LeEyU6dLjApOQdbEG9lkqHzxiQtOLEQ=="
+
},
+
"svelte@5.43.2_acorn@8.15.0": {
+
"integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==",
"dependencies": [
"@jridgewell/remapping",
"@jridgewell/sourcemap-codec",
···
"zimmerframe"
},
-
"tailwindcss@4.1.14": {
-
"integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="
+
"tailwindcss@4.1.16": {
+
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="
},
"tapable@2.3.0": {
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="
},
-
"tar@7.5.1": {
-
"integrity": "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==",
-
"dependencies": [
-
"@isaacs/fs-minipass",
-
"chownr",
-
"minipass",
-
"minizlib",
-
"yallist"
-
]
-
},
"tinyglobby@0.2.15_picomatch@4.0.3": {
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dependencies": [
···
"typescript"
},
+
"tslib@2.8.1": {
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+
},
"type-check@0.4.0": {
"integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
"dependencies": [
"prelude-ls"
},
-
"typescript-eslint@8.46.1_eslint@9.37.0_typescript@5.9.3_@typescript-eslint+parser@8.46.1__eslint@9.37.0__typescript@5.9.3": {
-
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
+
"typescript-eslint@8.46.3_eslint@9.39.0_typescript@5.9.3_@typescript-eslint+parser@8.46.3__eslint@9.39.0__typescript@5.9.3": {
+
"integrity": "sha512-bAfgMavTuGo+8n6/QQDVQz4tZ4f7Soqg53RbrlZQEoAltYop/XR4RAts/I0BrO3TTClTSTFJ0wYbla+P8cEWJA==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
···
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true
},
-
"undici-types@7.14.0": {
-
"integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="
+
"undici-types@7.16.0": {
+
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
},
"uri-js@4.4.1": {
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
···
"util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
-
"vite@7.1.10_@types+node@24.8.0_picomatch@4.0.3": {
-
"integrity": "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA==",
+
"vite@7.1.12_@types+node@24.10.0_picomatch@4.0.3": {
+
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dependencies": [
"@types/node",
"esbuild",
···
],
"bin": true
},
-
"vitefu@1.1.1_vite@7.1.10__@types+node@24.8.0__picomatch@4.0.3_@types+node@24.8.0": {
+
"vitefu@1.1.1_vite@7.1.12__@types+node@24.10.0__picomatch@4.0.3_@types+node@24.10.0": {
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dependencies": [
"vite"
···
"word-wrap@1.2.5": {
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="
},
-
"yallist@5.0.0": {
-
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="
-
},
"yaml@1.10.2": {
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
},
···
"workspace": {
"packageJson": {
"dependencies": [
-
"npm:@atcute/atproto@^3.1.7",
-
"npm:@atcute/bluesky@^3.2.7",
+
"npm:@atcute/atproto@^3.1.8",
+
"npm:@atcute/bluesky@^3.2.9",
"npm:@atcute/client@^4.0.5",
+
"npm:@atcute/identity-resolver@^1.1.4",
"npm:@atcute/identity@^1.1.1",
"npm:@atcute/lexicons@^1.2.2",
-
"npm:@eslint/compat@^1.4.0",
-
"npm:@eslint/js@^9.36.0",
-
"npm:@sveltejs/adapter-auto@^6.1.0",
-
"npm:@sveltejs/kit@^2.43.2",
-
"npm:@sveltejs/vite-plugin-svelte@^6.2.0",
+
"npm:@atcute/oauth-browser-client@^2.0.1",
+
"npm:@atcute/tid@^1.0.3",
+
"npm:@eslint/compat@^1.4.1",
+
"npm:@eslint/js@^9.39.0",
+
"npm:@floating-ui/dom@^1.7.4",
+
"npm:@iconify/svelte@^5.1.0",
+
"npm:@soffinal/websocket@~0.2.1",
+
"npm:@sveltejs/adapter-static@^3.0.10",
+
"npm:@sveltejs/kit@^2.48.4",
+
"npm:@sveltejs/vite-plugin-svelte@^6.2.1",
"npm:@tailwindcss/forms@~0.5.10",
-
"npm:@tailwindcss/vite@^4.1.13",
-
"npm:@types/node@24",
+
"npm:@tailwindcss/vite@^4.1.16",
+
"npm:@types/node@^24.10.0",
+
"npm:@wora/cache-persist@^2.2.1",
"npm:eslint-config-prettier@^10.1.8",
-
"npm:eslint-plugin-svelte@^3.12.4",
-
"npm:eslint@^9.36.0",
-
"npm:globals@^16.4.0",
+
"npm:eslint-plugin-svelte@^3.13.0",
+
"npm:eslint@^9.39.0",
+
"npm:globals@^16.5.0",
+
"npm:hash-wasm@^4.12.0",
"npm:lru-cache@^11.2.2",
"npm:prettier-plugin-svelte@^3.4.0",
"npm:prettier-plugin-tailwindcss@~0.6.14",
"npm:prettier@^3.6.2",
-
"npm:svelte-check@^4.3.2",
-
"npm:svelte@^5.39.5",
-
"npm:tailwindcss@^4.1.13",
-
"npm:typescript-eslint@^8.44.1",
-
"npm:typescript@^5.9.2",
-
"npm:vite@^7.1.7"
+
"npm:svelte-awesome-color-picker@^4.1.0",
+
"npm:svelte-check@^4.3.3",
+
"npm:svelte-device-info@^1.0.6",
+
"npm:svelte-infinite@~0.5.1",
+
"npm:svelte-portal@^2.2.1",
+
"npm:svelte@^5.43.2",
+
"npm:tailwindcss@^4.1.16",
+
"npm:typescript-eslint@^8.46.3",
+
"npm:typescript@^5.9.3",
+
"npm:vite@^7.1.12"
+76
flake.lock
···
+
{
+
"nodes": {
+
"naked-shell": {
+
"locked": {
+
"lastModified": 1681286841,
+
"narHash": "sha256-3XlJrwlR0nBiREnuogoa5i1b4+w/XPe0z8bbrJASw0g=",
+
"owner": "90-008",
+
"repo": "mk-naked-shell",
+
"rev": "7612f828dd6f22b7fb332cc69440e839d7ffe6bd",
+
"type": "github"
+
},
+
"original": {
+
"owner": "90-008",
+
"repo": "mk-naked-shell",
+
"type": "github"
+
}
+
},
+
"nixpkgs": {
+
"locked": {
+
"lastModified": 1761850514,
+
"narHash": "sha256-qmg1yC6ybzH0/w4Bupx1hpgTS5MTl2qBMoD+DFx3hWM=",
+
"owner": "nixos",
+
"repo": "nixpkgs",
+
"rev": "1c3d5f4e01f0b18b508be644d9d6a196fb7ed1f5",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nixos",
+
"repo": "nixpkgs",
+
"type": "github"
+
}
+
},
+
"nixpkgs-lib": {
+
"locked": {
+
"lastModified": 1754788789,
+
"narHash": "sha256-x2rJ+Ovzq0sCMpgfgGaaqgBSwY+LST+WbZ6TytnT9Rk=",
+
"owner": "nix-community",
+
"repo": "nixpkgs.lib",
+
"rev": "a73b9c743612e4244d865a2fdee11865283c04e6",
+
"type": "github"
+
},
+
"original": {
+
"owner": "nix-community",
+
"repo": "nixpkgs.lib",
+
"type": "github"
+
}
+
},
+
"parts": {
+
"inputs": {
+
"nixpkgs-lib": "nixpkgs-lib"
+
},
+
"locked": {
+
"lastModified": 1760948891,
+
"narHash": "sha256-TmWcdiUUaWk8J4lpjzu4gCGxWY6/Ok7mOK4fIFfBuU4=",
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"rev": "864599284fc7c0ba6357ed89ed5e2cd5040f0c04",
+
"type": "github"
+
},
+
"original": {
+
"owner": "hercules-ci",
+
"repo": "flake-parts",
+
"type": "github"
+
}
+
},
+
"root": {
+
"inputs": {
+
"naked-shell": "naked-shell",
+
"nixpkgs": "nixpkgs",
+
"parts": "parts"
+
}
+
}
+
},
+
"root": "root",
+
"version": 7
+
}
+33
flake.nix
···
+
{
+
inputs.parts.url = "github:hercules-ci/flake-parts";
+
inputs.nixpkgs.url = "github:nixos/nixpkgs";
+
inputs.naked-shell.url = "github:90-008/mk-naked-shell";
+
+
outputs = inp:
+
inp.parts.lib.mkFlake {inputs = inp;} {
+
systems = ["x86_64-linux"];
+
imports = [inp.naked-shell.flakeModule];
+
perSystem = {
+
config,
+
system,
+
...
+
}: let
+
pkgs = inp.nixpkgs.legacyPackages.${system};
+
in {
+
devShells.default = config.mk-naked-shell.lib.mkNakedShell {
+
name = "nucleus-devshell";
+
packages = with pkgs; [
+
nodejs-slim_latest deno
+
];
+
shellHook = ''
+
export PATH="$PATH:$PWD/node_modules/.bin"
+
'';
+
};
+
packages.nucleus-modules = pkgs.callPackage ./nix/modules.nix {};
+
packages.nucleus = pkgs.callPackage ./nix {
+
inherit (config.packages) nucleus-modules;
+
};
+
packages.default = config.packages.nucleus;
+
};
+
};
+
}
+55
nix/default.nix
···
+
{
+
lib,
+
stdenv,
+
deno,
+
nodejs,
+
makeBinaryWrapper,
+
nucleus-modules,
+
PUBLIC_DOMAIN ? "http://localhost:5173",
+
}:
+
stdenv.mkDerivation {
+
name = "nucleus";
+
+
src = lib.fileset.toSource {
+
root = ../.;
+
fileset = lib.fileset.unions [
+
../src
+
../static
+
../deno.lock
+
../package.json
+
../svelte.config.js
+
../tsconfig.json
+
../vite.config.ts
+
];
+
};
+
+
nativeBuildInputs = [makeBinaryWrapper];
+
buildInputs = [deno];
+
+
inherit PUBLIC_DOMAIN;
+
+
dontCheck = true;
+
+
configurePhase = ''
+
runHook preConfigure
+
cp -R --no-preserve=ownership ${nucleus-modules} node_modules
+
find node_modules -type d -exec chmod 755 {} \;
+
substituteInPlace node_modules/.bin/vite \
+
--replace-fail "/usr/bin/env node" "${nodejs}/bin/node"
+
runHook postConfigure
+
'';
+
buildPhase = ''
+
runHook preBuild
+
HOME=$TMPDIR deno run --cached-only build
+
runHook postBuild
+
'';
+
installPhase = ''
+
runHook preInstall
+
+
mkdir -p $out/bin
+
cp -R ./build/* $out
+
# cp -R ./node_modules $out
+
+
runHook postInstall
+
'';
+
}
+35
nix/modules.nix
···
+
{
+
lib,
+
stdenv,
+
deno,
+
}:
+
stdenv.mkDerivation {
+
name = "nucleus-modules";
+
+
src = lib.fileset.toSource {
+
root = ../.;
+
fileset = lib.fileset.unions [
+
../deno.lock
+
../package.json
+
];
+
};
+
+
outputHash = "sha256-s5rq8htDjR0I8MxPtLq1NYIywXGEdYbZZvE7I5+TCIU=";
+
outputHashAlgo = "sha256";
+
outputHashMode = "recursive";
+
+
nativeBuildInputs = [deno];
+
+
dontConfigure = true;
+
dontCheck = true;
+
dontFixup = true;
+
dontPatchShebangs = true;
+
+
buildPhase = ''
+
HOME=$TMPDIR deno install --frozen --seed 8008135
+
'';
+
installPhase = ''
+
cp -R node_modules $out
+
ls -la $out
+
'';
+
}
+32 -20
package.json
···
"version": "0.0.1",
"type": "module",
"scripts": {
-
"dev": "vite dev",
+
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
···
"lint": "prettier --check . && eslint ."
},
"dependencies": {
-
"@atcute/atproto": "^3.1.7",
-
"@atcute/bluesky": "^3.2.7",
+
"@atcute/atproto": "^3.1.8",
+
"@atcute/bluesky": "^3.2.9",
"@atcute/client": "^4.0.5",
"@atcute/identity": "^1.1.1",
+
"@atcute/identity-resolver": "^1.1.4",
"@atcute/lexicons": "^1.2.2",
-
"lru-cache": "^11.2.2"
+
"@atcute/oauth-browser-client": "^2.0.1",
+
"@atcute/tid": "^1.0.3",
+
"@floating-ui/dom": "^1.7.4",
+
"@soffinal/websocket": "^0.2.1",
+
"@wora/cache-persist": "^2.2.1",
+
"hash-wasm": "^4.12.0",
+
"lru-cache": "^11.2.2",
+
"svelte-device-info": "^1.0.6",
+
"svelte-infinite": "^0.5.1",
+
"svelte-portal": "^2.2.1"
},
"devDependencies": {
-
"@eslint/compat": "^1.4.0",
-
"@eslint/js": "^9.36.0",
-
"@sveltejs/adapter-auto": "^6.1.0",
-
"@sveltejs/kit": "^2.43.2",
-
"@sveltejs/vite-plugin-svelte": "^6.2.0",
+
"@eslint/compat": "^1.4.1",
+
"@eslint/js": "^9.39.0",
+
"@iconify/svelte": "^5.1.0",
+
"@sveltejs/adapter-static": "^3.0.10",
+
"@sveltejs/kit": "^2.48.4",
+
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/forms": "^0.5.10",
-
"@tailwindcss/vite": "^4.1.13",
-
"@types/node": "^24",
-
"eslint": "^9.36.0",
+
"@tailwindcss/vite": "^4.1.16",
+
"@types/node": "^24.10.0",
+
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
-
"eslint-plugin-svelte": "^3.12.4",
-
"globals": "^16.4.0",
+
"eslint-plugin-svelte": "^3.13.0",
+
"globals": "^16.5.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
-
"svelte": "^5.39.5",
-
"svelte-check": "^4.3.2",
-
"tailwindcss": "^4.1.13",
-
"typescript": "^5.9.2",
-
"typescript-eslint": "^8.44.1",
-
"vite": "^7.1.7"
+
"svelte": "^5.43.2",
+
"svelte-awesome-color-picker": "^4.1.0",
+
"svelte-check": "^4.3.3",
+
"tailwindcss": "^4.1.16",
+
"typescript": "^5.9.3",
+
"typescript-eslint": "^8.46.3",
+
"vite": "^7.1.12"
}
}
resources/screenshot.png

This is a binary file and will not be displayed.

+95
src/app.css
···
@import 'tailwindcss';
+
@plugin '@tailwindcss/forms';
+
+
@theme {
+
@keyframes fade-in-scale {
+
0% {
+
opacity: 0;
+
transform: scale(0.95);
+
}
+
100% {
+
opacity: 1;
+
transform: scale(1);
+
}
+
}
+
}
+
+
@utility animate-fade-in-scale {
+
animation: fade-in-scale 0.2s ease-out forwards;
+
}
+
+
@utility animate-fade-in-scale-fast {
+
animation: fade-in-scale 0.1s ease-out forwards;
+
}
+
+
@utility single-line-input {
+
@apply w-full rounded-sm border-2 border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3 px-3 py-2 font-medium transition-all;
+
&:focus {
+
@apply scale-[1.02] border-(--nucleus-accent)/80 bg-(--nucleus-accent)/10 [box-shadow:none] outline-none;
+
}
+
}
+
+
@utility action-button {
+
@apply rounded-sm border-2 border-(--nucleus-accent) px-3 py-2 font-semibold text-(--nucleus-accent) transition-all hover:scale-105 hover:bg-(--nucleus-accent)/20;
+
}
+
+
@utility error-disclaimer {
+
@apply rounded-sm border-2 border-red-500 p-2;
+
background-color: color-mix(in srgb, var(--color-red-500) 15%, var(--nucleus-bg));
+
p {
+
@apply text-base text-wrap wrap-break-word text-red-500;
+
}
+
}
+
+
:root {
+
scrollbar-width: thin;
+
scrollbar-color: var(--nucleus-accent) var(--nucleus-bg);
+
}
+
+
button {
+
@apply hover:cursor-pointer;
+
}
+
+
a {
+
&:hover {
+
@apply cursor-pointer underline;
+
}
+
}
+
+
.grain:before {
+
content: '';
+
background-color: transparent;
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 600 600'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='4' stitchTiles='stitch' /%3E%3CfeComponentTransfer%3E%3CfeFuncA type='linear' slope='2' intercept='-0.5' /%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)' /%3E%3C/svg%3E");
+
background-repeat: repeat;
+
background-size: 40vmax;
+
opacity: 0.08;
+
top: 0;
+
left: 0;
+
position: fixed;
+
width: 100%;
+
height: 100%;
+
pointer-events: none;
+
}
+
+
.color-picker {
+
--cp-bg-color: var(--nucleus-bg);
+
--cp-border-color: var(--nucleus-accent);
+
--cp-text-color: var(--nucleus-fg);
+
--cp-input-color: color-mix(in srgb, var(--nucleus-accent) 10%, transparent);
+
--cp-button-hover-color: color-mix(in srgb, var(--nucleus-accent) 30%, transparent);
+
--picker-height: 8rem;
+
--picker-width: 8rem;
+
}
+
+
.animate-pulse-highlight {
+
animation: pulse-highlight 0.6s ease-in-out 3;
+
}
+
+
@keyframes pulse-highlight {
+
0%,
+
100% {
+
box-shadow: 0 0 0 0 var(--nucleus-selected-post);
+
}
+
50% {
+
box-shadow: 0 0 20px 5px var(--nucleus-selected-post);
+
}
+
}
+205
src/components/AccountSelector.svelte
···
+
<script lang="ts">
+
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
+
import { AtpClient } from '$lib/at/client';
+
import type { Handle } from '@atcute/lexicons';
+
import ProfilePicture from './ProfilePicture.svelte';
+
import PfpPlaceholder from './PfpPlaceholder.svelte';
+
import Popup from './Popup.svelte';
+
import Dropdown from './Dropdown.svelte';
+
import { flow } from '$lib/at/oauth';
+
import { isHandle, type AtprotoDid } from '@atcute/lexicons/syntax';
+
import Icon from '@iconify/svelte';
+
+
interface Props {
+
client: AtpClient;
+
accounts: Array<Account>;
+
selectedDid?: AtprotoDid | null;
+
onAccountSelected: (did: AtprotoDid) => void;
+
onLogout: (did: AtprotoDid) => void;
+
}
+
+
let {
+
client,
+
accounts = [],
+
selectedDid = $bindable(null),
+
onAccountSelected,
+
onLogout
+
}: Props = $props();
+
+
let isDropdownOpen = $state(false);
+
let isLoginModalOpen = $state(false);
+
let loginHandle = $state('');
+
let loginError = $state('');
+
let isLoggingIn = $state(false);
+
+
const toggleDropdown = () => (isDropdownOpen = !isDropdownOpen);
+
const closeDropdown = () => (isDropdownOpen = false);
+
+
const selectAccount = (did: AtprotoDid) => {
+
onAccountSelected(did);
+
closeDropdown();
+
};
+
+
const openLoginModal = () => {
+
isLoginModalOpen = true;
+
closeDropdown();
+
loginHandle = '';
+
loginError = '';
+
// HACK: i hate this but it works so it doesnt really matter
+
setTimeout(() => document.getElementById('handle')?.focus(), 100);
+
};
+
+
const closeLoginModal = () => {
+
document.getElementById('handle')?.blur();
+
isLoginModalOpen = false;
+
loginHandle = '';
+
loginError = '';
+
};
+
+
const handleLogin = async () => {
+
try {
+
if (!loginHandle) throw 'please enter handle';
+
+
isLoggingIn = true;
+
loginError = '';
+
+
let handle: Handle;
+
if (isHandle(loginHandle)) handle = loginHandle;
+
else throw 'handle is invalid';
+
+
let did = await client.resolveHandle(handle);
+
if (!did.ok) throw did.error;
+
+
await initiateLogin(did.value, handle);
+
} catch (error) {
+
loginError = `login failed: ${error}`;
+
loggingIn.set(null);
+
} finally {
+
isLoggingIn = false;
+
}
+
};
+
+
const initiateLogin = async (did: AtprotoDid, handle: Handle | null) => {
+
loggingIn.set({ did, handle });
+
const result = await flow.start(handle ?? did);
+
if (!result.ok) throw result.error;
+
};
+
+
const handleKeydown = (event: KeyboardEvent) => {
+
if (event.key === 'Enter' && !isLoggingIn) handleLogin();
+
};
+
</script>
+
+
<Dropdown
+
class="min-w-52 rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl"
+
bind:isOpen={isDropdownOpen}
+
placement="top-start"
+
>
+
{#snippet trigger()}
+
<button
+
onclick={toggleDropdown}
+
class="flex h-13 w-13 items-center justify-center rounded-sm shadow-md transition-all hover:scale-110 hover:shadow-xl hover:saturate-150"
+
>
+
{#if selectedDid}
+
<ProfilePicture {client} did={selectedDid} size={13} />
+
{:else}
+
<PfpPlaceholder color="var(--nucleus-accent)" size={13} />
+
{/if}
+
</button>
+
{/snippet}
+
+
{#if accounts.length > 0}
+
<div class="p-2">
+
{#each accounts as account (account.did)}
+
{@const color = generateColorForDid(account.did)}
+
{#snippet action(name: string, icon: string, onClick: () => void)}
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
title={name}
+
onclick={onClick}
+
class="hidden text-(--nucleus-accent) transition-all group-hover:block hover:scale-[1.2] hover:shadow-md"
+
>
+
<Icon class="h-5 w-5" {icon} />
+
</div>
+
{/snippet}
+
<button
+
onclick={() => selectAccount(account.did)}
+
class="
+
group flex w-full items-center gap-3 rounded-sm p-2 text-left text-sm font-medium transition-all
+
{account.did === selectedDid ? 'shadow-lg' : ''}
+
"
+
style="color: {color}; background: {account.did === selectedDid
+
? `linear-gradient(135deg, color-mix(in srgb, var(--nucleus-accent) 20%, transparent), color-mix(in srgb, var(--nucleus-accent2) 20%, transparent))`
+
: 'transparent'};"
+
>
+
<span>@{account.handle}</span>
+
+
<div class="grow"></div>
+
+
{@render action('relogin', 'heroicons:arrow-path-rounded-square-solid', () =>
+
initiateLogin(account.did, account.handle)
+
)}
+
{@render action('logout', 'heroicons:trash-solid', () => onLogout(account.did))}
+
+
{#if account.did === selectedDid}
+
<Icon
+
icon="heroicons:check-16-solid"
+
class="h-5 w-5 scale-125 text-(--nucleus-accent) group-hover:hidden"
+
/>
+
{/if}
+
</button>
+
{/each}
+
</div>
+
<div class="mx-2 h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
+
{/if}
+
<button
+
onclick={openLoginModal}
+
class="group flex w-full origin-left items-center gap-3 p-3 text-left text-sm font-semibold text-(--nucleus-accent) transition-all hover:scale-[1.1]"
+
>
+
<Icon class="h-5 w-5 scale-[130%]" icon="heroicons:plus-16-solid" />
+
<span>add account</span>
+
</button>
+
</Dropdown>
+
+
<Popup bind:isOpen={isLoginModalOpen} onClose={closeLoginModal} title="add account">
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div class="space-y-2" onkeydown={handleKeydown}>
+
<div>
+
<label for="handle" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
account handle
+
</label>
+
<input
+
id="handle"
+
type="text"
+
bind:value={loginHandle}
+
placeholder="example.bsky.social"
+
class="single-line-input border-(--nucleus-accent)/40 bg-(--nucleus-accent)/3"
+
disabled={isLoggingIn}
+
/>
+
</div>
+
+
{#if loginError}
+
<div class="error-disclaimer">
+
<p>
+
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
+
{loginError}
+
</p>
+
</div>
+
{/if}
+
+
<div class="flex gap-3 pt-3">
+
<button onclick={closeLoginModal} class="flex-1 action-button" disabled={isLoggingIn}>
+
cancel
+
</button>
+
<button
+
onclick={handleLogin}
+
class="flex-1 action-button border-transparent text-(--nucleus-fg)"
+
style="background: linear-gradient(135deg, var(--nucleus-accent), var(--nucleus-accent2));"
+
disabled={isLoggingIn}
+
>
+
{isLoggingIn ? 'logging in...' : 'login'}
+
</button>
+
</div>
+
</div>
+
</Popup>
+657 -22
src/components/BskyPost.svelte
···
<script lang="ts">
-
import type { AtpClient } from '$lib/at/client';
-
import { AppBskyFeedPost } from '@atcute/bluesky';
-
import type { ActorIdentifier, RecordKey } from '@atcute/lexicons';
+
import { type AtpClient } from '$lib/at/client';
+
import {
+
AppBskyActorProfile,
+
AppBskyEmbedExternal,
+
AppBskyEmbedImages,
+
AppBskyEmbedVideo,
+
AppBskyFeedPost
+
} from '@atcute/bluesky';
+
import {
+
parseCanonicalResourceUri,
+
type ActorIdentifier,
+
type CanonicalResourceUri,
+
type Did,
+
type Nsid,
+
type RecordKey,
+
type ResourceUri
+
} from '@atcute/lexicons';
+
import { expect, ok } from '$lib/result';
+
import { accounts, generateColorForDid } from '$lib/accounts';
+
import ProfilePicture from './ProfilePicture.svelte';
+
import { isBlob } from '@atcute/lexicons/interfaces';
+
import { blob, img } from '$lib/cdn';
+
import BskyPost from './BskyPost.svelte';
+
import Icon from '@iconify/svelte';
+
import { type Backlink, type BacklinksSource } from '$lib/at/constellation';
+
import { clients, postActions, posts, pulsingPostId, type PostActions } from '$lib/state.svelte';
+
import * as TID from '@atcute/tid';
+
import type { PostWithUri } from '$lib/at/fetch';
+
import { onMount } from 'svelte';
+
import { isActorIdentifier, type AtprotoDid } from '@atcute/lexicons/syntax';
+
import { derived } from 'svelte/store';
+
import Device from 'svelte-device-info';
+
import Dropdown from './Dropdown.svelte';
+
import { type AppBskyEmbeds } from '$lib/at/types';
+
import { settings } from '$lib/settings';
interface Props {
client: AtpClient;
-
identifier: ActorIdentifier;
+
// post
+
did: Did;
rkey: RecordKey;
+
// replyBacklinks?: Backlinks;
+
quoteDepth?: number;
+
data?: PostWithUri;
+
mini?: boolean;
+
isOnPostComposer?: boolean;
+
onQuote?: (quote: PostWithUri) => void;
+
onReply?: (reply: PostWithUri) => void;
}
-
const { client, identifier, rkey }: Props = $props();
+
const {
+
client,
+
did,
+
rkey,
+
quoteDepth = 0,
+
data,
+
mini,
+
onQuote,
+
onReply,
+
isOnPostComposer = false /* replyBacklinks */
+
}: Props = $props();
-
const post = client.getRecord(AppBskyFeedPost.mainSchema, identifier, rkey);
+
const selectedDid = $derived(client.user?.did ?? null);
+
const actionClient = $derived(clients.get(did as AtprotoDid));
+
+
const aturi: CanonicalResourceUri = `at://${did}/app.bsky.feed.post/${rkey}`;
+
const color = generateColorForDid(did);
+
+
let handle: ActorIdentifier = $state(did);
+
const didDoc = client.resolveDidDoc(did).then((res) => {
+
if (res.ok) handle = res.value.handle;
+
return res;
+
});
+
const post = data
+
? Promise.resolve(ok(data))
+
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
+
let profile: AppBskyActorProfile.Main | null = $state(null);
+
onMount(async () => {
+
const p = await client.getProfile(did);
+
if (!p.ok) return;
+
profile = p.value;
+
console.log(profile.description);
+
});
+
// const replies = replyBacklinks
+
// ? Promise.resolve(ok(replyBacklinks))
+
// : client.getBacklinks(
+
// identifier,
+
// 'app.bsky.feed.post',
+
// rkey,
+
// 'app.bsky.feed.post:reply.parent.uri'
+
// );
+
+
const postId = `timeline-post-${aturi}-${quoteDepth}`;
+
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
+
+
const scrollToAndPulse = (targetUri: ResourceUri) => {
+
const targetId = `timeline-post-${targetUri}-0`;
+
console.log(`Scrolling to ${targetId}`);
+
const element = document.getElementById(targetId);
+
if (!element) return;
+
+
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+
setTimeout(() => {
+
document.documentElement.style.setProperty(
+
'--nucleus-selected-post',
+
generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
+
);
+
pulsingPostId.set(targetId);
+
// Clear pulse after animation
+
setTimeout(() => pulsingPostId.set(null), 1200);
+
}, 400);
+
};
const getEmbedText = (embedType: string) => {
switch (embedType) {
case 'app.bsky.embed.external':
-
return 'contains external link';
+
return '๐Ÿ”— has external link';
case 'app.bsky.embed.record':
-
return 'quotes post';
+
return '๐Ÿ’ฌ has quote';
case 'app.bsky.embed.images':
-
return 'contains images';
+
return '๐Ÿ–ผ๏ธ has images';
case 'app.bsky.embed.video':
-
return 'contains video';
+
return '๐ŸŽฅ has video';
case 'app.bsky.embed.recordWithMedia':
-
return 'quotes post with media';
+
return '๐Ÿ“Ž has quote with media';
default:
-
return 'contains unknown embed';
+
return 'โ“ has unknown embed';
}
};
+
+
const getRelativeTime = (date: Date) => {
+
const now = new Date();
+
const diff = now.getTime() - date.getTime();
+
const seconds = Math.floor(diff / 1000);
+
const minutes = Math.floor(seconds / 60);
+
const hours = Math.floor(minutes / 60);
+
const days = Math.floor(hours / 24);
+
const months = Math.floor(days / 30);
+
const years = Math.floor(months / 12);
+
+
if (years > 0) return `${years}y`;
+
if (months > 0) return `${months}m`;
+
if (days > 0) return `${days}d`;
+
if (hours > 0) return `${hours}h`;
+
if (minutes > 0) return `${minutes}m`;
+
if (seconds > 0) return `${seconds}s`;
+
return 'now';
+
};
+
+
const findBacklink = $derived(async (toDid: AtprotoDid, source: BacklinksSource) => {
+
const backlinks = await client.getBacklinks(did, 'app.bsky.feed.post', rkey, source);
+
if (!backlinks.ok) return null;
+
return backlinks.value.records.find((r) => r.did === toDid) ?? null;
+
});
+
+
let findAllBacklinks = async (did: AtprotoDid | null) => {
+
if (!did) return;
+
if (postActions.has(`${did}:${aturi}`)) return;
+
const backlinks = await Promise.all([
+
findBacklink(did, 'app.bsky.feed.like:subject.uri'),
+
findBacklink(did, 'app.bsky.feed.repost:subject.uri')
+
// findBacklink('app.bsky.feed.post:reply.parent.uri'),
+
// findBacklink('app.bsky.feed.post:embed.record.uri')
+
]);
+
const actions: PostActions = {
+
like: backlinks[0],
+
repost: backlinks[1]
+
// reply: backlinks[2],
+
// quote: backlinks[3]
+
};
+
console.log('findAllBacklinks', did, aturi, actions);
+
postActions.set(`${did}:${aturi}`, actions);
+
};
+
onMount(() => {
+
// findAllBacklinks($selectedDid);
+
accounts.subscribe((accs) => {
+
accs.map((acc) => acc.did).forEach((did) => findAllBacklinks(did));
+
});
+
});
+
+
const toggleLink = async (link: Backlink | null, collection: Nsid): Promise<Backlink | null> => {
+
// console.log('toggleLink', selectedDid, link, collection);
+
if (!selectedDid) return null;
+
const _post = await post;
+
if (!_post.ok) return null;
+
if (!link) {
+
if (_post.value.cid) {
+
const record = {
+
$type: collection,
+
subject: {
+
cid: _post.value.cid,
+
uri: aturi
+
},
+
createdAt: new Date().toISOString()
+
};
+
const rkey = TID.now();
+
// todo: handle errors
+
client.atcute?.post('com.atproto.repo.createRecord', {
+
input: {
+
repo: selectedDid,
+
collection,
+
record,
+
rkey
+
}
+
});
+
return {
+
collection,
+
did: selectedDid,
+
rkey
+
};
+
}
+
} else {
+
// todo: handle errors
+
client.atcute?.post('com.atproto.repo.deleteRecord', {
+
input: {
+
repo: link.did,
+
collection: link.collection,
+
rkey: link.rkey
+
}
+
});
+
return null;
+
}
+
return link;
+
};
+
+
let actionsOpen = $state(false);
+
let actionsPos = $state({ x: 0, y: 0 });
+
+
const handleRightClick = (event: MouseEvent) => {
+
actionsOpen = true;
+
actionsPos = { x: event.clientX, y: event.clientY };
+
event.preventDefault();
+
event.stopPropagation();
+
};
+
+
let deleteState: 'waiting' | 'confirm' | 'deleted' = $state('waiting');
+
$effect(() => {
+
if (deleteState === 'confirm' && !actionsOpen) deleteState = 'waiting';
+
});
+
+
const deletePost = () => {
+
if (deleteState === 'deleted') return;
+
if (deleteState === 'waiting') {
+
deleteState = 'confirm';
+
return;
+
}
+
+
actionClient?.atcute
+
?.post('com.atproto.repo.deleteRecord', {
+
input: {
+
collection: 'app.bsky.feed.post',
+
repo: did,
+
rkey
+
}
+
})
+
.then((result) => {
+
if (!result.ok) return;
+
posts.get(did)?.delete(aturi);
+
deleteState = 'deleted';
+
});
+
actionsOpen = false;
+
};
+
+
let profileOpen = $state(false);
+
let profilePopoutShowDid = $state(false);
</script>
-
{#await post}
-
loading post...
-
{:then post}
-
{#if post.ok}
-
{@const record = post.value}
-
{identifier} - [{record.embed ? getEmbedText(record.embed.$type) : null}]
-
{record.text}
-
{:else}
-
error fetching post: {post.error}
+
{#snippet embedBadge(embed: AppBskyEmbeds)}
+
<span
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
+
style="
+
background: color-mix(in srgb, {mini ? 'var(--nucleus-fg)' : color} 10%, transparent);
+
color: {mini ? 'var(--nucleus-fg)' : color};
+
"
+
>
+
{getEmbedText(embed.$type!)}
+
</span>
+
{/snippet}
+
+
{#snippet profileInline()}
+
<button
+
class="
+
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-200' : ''}
+
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
+
"
+
style="color: {color};"
+
onclick={() => (profileOpen = !profileOpen)}
+
>
+
<ProfilePicture {client} {did} size={8} />
+
+
{#if profile}
+
<span class="w-min max-w-sm min-w-0 overflow-hidden text-nowrap overflow-ellipsis"
+
>{profile.displayName}</span
+
><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span>
+
{:else}
+
{handle}
+
{/if}
+
</button>
+
{/snippet}
+
+
<!-- eslint-disable svelte/no-navigation-without-resolve -->
+
{#snippet profilePopout()}
+
{@const profileDesc = profile?.description?.trim() ?? ''}
+
<Dropdown
+
class="post-dropdown max-w-xl gap-2! p-2.5! backdrop-blur-3xl! backdrop-brightness-25!"
+
style="background: {color}36; border-color: {color}99;"
+
bind:isOpen={profileOpen}
+
trigger={profileInline}
+
>
+
<div class="flex items-center gap-2">
+
<ProfilePicture {client} {did} size={20} />
+
+
<div class="flex flex-col items-start overflow-hidden overflow-ellipsis">
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
+
{profile?.displayName ?? handle}
+
{#if profile?.pronouns}
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
+
{/if}
+
</span>
+
<button
+
oncontextmenu={(e) => {
+
const node = e.target as Node;
+
const selection = window.getSelection() ?? new Selection();
+
const range = document.createRange();
+
range.selectNodeContents(node);
+
selection.removeAllRanges();
+
selection.addRange(range);
+
e.stopPropagation();
+
}}
+
onclick={() => (profilePopoutShowDid = !profilePopoutShowDid)}
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
+
>
+
{profilePopoutShowDid ? did : `@${handle}`}
+
</button>
+
{#if profile?.website}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
href={profile.website}
+
class="text-sm text-nowrap opacity-60">{profile.website}</a
+
>
+
{/if}
+
</div>
+
</div>
+
+
{#if profileDesc.length > 0}
+
<p class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
+
{#each profileDesc.split(/(\s)/) as line, idx (idx)}
+
{#if line === '\n'}
+
<br />
+
{:else if isActorIdentifier(line.replace(/^@/, ''))}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
class="text-(--nucleus-accent2)"
+
href={`${$settings.socialAppUrl}/profile/${line.replace(/^@/, '')}`}>{line}</a
+
>
+
{:else if line.startsWith('https://')}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
class="text-(--nucleus-accent2)"
+
href={line}>{line.replace(/https?:\/\//, '')}</a
+
>
+
{:else}
+
{line}
+
{/if}
+
{/each}
+
</p>
+
{/if}
+
</Dropdown>
+
{/snippet}
+
+
{#if mini}
+
<div class="text-sm opacity-60">
+
{#await post}
+
loading...
+
{:then post}
+
{#if post.ok}
+
{@const record = post.value.record}
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
onclick={() => scrollToAndPulse(post.value.uri)}
+
class="select-none hover:cursor-pointer hover:underline"
+
>
+
<span style="color: {color};">@{handle}</span>:
+
{#if record.embed}
+
{@render embedBadge(record.embed)}
+
{/if}
+
<span title={record.text}>{record.text}</span>
+
</div>
+
{:else}
+
{post.error}
+
{/if}
+
{/await}
+
</div>
+
{:else}
+
{#await post}
+
<div
+
class="rounded-sm border-2 p-2 text-center backdrop-blur-sm"
+
style="background: {color}18; border-color: {color}66;"
+
>
+
<div
+
class="
+
inline-block h-6 w-6 animate-spin rounded-full
+
border-3 border-(--nucleus-accent) border-l-transparent
+
"
+
></div>
+
<p class="mt-3 text-sm font-medium opacity-60">loading post...</p>
+
</div>
+
{:then post}
+
{#if post.ok}
+
{@const record = post.value.record}
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
id="timeline-post-{post.value.uri}-{quoteDepth}"
+
oncontextmenu={handleRightClick}
+
class="
+
group rounded-sm border-2 p-2 shadow-lg backdrop-blur-sm transition-all
+
{$isPulsing ? 'animate-pulse-highlight' : ''}
+
{isOnPostComposer ? 'backdrop-brightness-20' : ''}
+
"
+
style="
+
background: {color}{isOnPostComposer
+
? '36'
+
: Math.floor(24.0 * (quoteDepth * 0.5 + 1.0)).toString(16)};
+
border-color: {color}{isOnPostComposer ? '99' : '66'};
+
"
+
>
+
<div
+
class="
+
mb-3 flex w-fit max-w-full items-center gap-1 rounded-sm pr-1
+
"
+
style="background: {color}33;"
+
>
+
{@render profilePopout()}
+
<span>ยท</span>
+
<span
+
title={new Date(record.createdAt).toLocaleString()}
+
class="pl-0.5 text-nowrap text-(--nucleus-fg)/67"
+
>
+
{getRelativeTime(new Date(record.createdAt))}
+
</span>
+
</div>
+
<p class="leading-normal text-wrap wrap-break-word">
+
{record.text}
+
{#if isOnPostComposer && record.embed}
+
{@render embedBadge(record.embed)}
+
{/if}
+
</p>
+
{#if !isOnPostComposer && record.embed}
+
{@const embed = record.embed}
+
<div class="mt-2">
+
{@render postEmbed(embed)}
+
</div>
+
{/if}
+
{#if !isOnPostComposer}
+
{@const backlinks = postActions.get(`${selectedDid!}:${post.value.uri}`)}
+
{@render postControls(post.value, backlinks)}
+
{/if}
+
</div>
+
{:else}
+
<div class="error-disclaimer">
+
<p class="text-sm font-medium">error: {post.error}</p>
+
</div>
+
{/if}
+
{/await}
+
{/if}
+
+
{#snippet postEmbed(embed: AppBskyEmbeds)}
+
{#snippet embedMedia(
+
embed: AppBskyEmbedImages.Main | AppBskyEmbedVideo.Main | AppBskyEmbedExternal.Main
+
)}
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div oncontextmenu={(e) => e.stopPropagation()}>
+
{#if embed.$type === 'app.bsky.embed.images'}
+
<!-- todo: improve how images are displayed, and pop out on click -->
+
{#each embed.images as image (image.image)}
+
{#if isBlob(image.image)}
+
<img
+
class="w-full rounded-sm"
+
src={img('feed_thumbnail', did, image.image.ref.$link)}
+
alt={image.alt}
+
/>
+
{/if}
+
{/each}
+
{:else if embed.$type === 'app.bsky.embed.video'}
+
{#if isBlob(embed.video)}
+
{#await didDoc then didDoc}
+
{#if didDoc.ok}
+
<!-- svelte-ignore a11y_media_has_caption -->
+
<video
+
class="rounded-sm"
+
src={blob(didDoc.value.pds, did, embed.video.ref.$link)}
+
controls
+
></video>
+
{/if}
+
{/await}
+
{/if}
+
{/if}
+
</div>
+
{/snippet}
+
{#snippet embedPost(uri: ResourceUri)}
+
{#if quoteDepth < 2}
+
{@const parsedUri = expect(parseCanonicalResourceUri(uri))}
+
<!-- reject recursive quotes -->
+
{#if !(did === parsedUri.repo && rkey === parsedUri.rkey)}
+
<BskyPost
+
{client}
+
quoteDepth={quoteDepth + 1}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
{isOnPostComposer}
+
{onQuote}
+
{onReply}
+
/>
+
{:else}
+
<span>you think you're funny with that recursive quote but i'm onto you</span>
+
{/if}
+
{:else}
+
{@render embedBadge(embed)}
+
{/if}
+
{/snippet}
+
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
+
{@render embedMedia(embed)}
+
{:else if embed.$type === 'app.bsky.embed.record'}
+
{@render embedPost(embed.record.uri)}
+
{:else if embed.$type === 'app.bsky.embed.recordWithMedia'}
+
<div class="space-y-1.5">
+
{@render embedPost(embed.record.record.uri)}
+
{@render embedMedia(embed.media)}
+
</div>
{/if}
-
{/await}
+
<!-- todo: implement external link embeds -->
+
{/snippet}
+
+
{#snippet postControls(post: PostWithUri, backlinks?: PostActions)}
+
{#snippet control(
+
name: string,
+
icon: string,
+
onClick: (e: MouseEvent) => void,
+
isFull?: boolean,
+
hasSolid?: boolean
+
)}
+
<button
+
class="
+
px-2 py-1.5 text-(--nucleus-fg)/90 transition-all
+
duration-100 hover:[backdrop-filter:brightness(120%)]
+
"
+
onclick={(e) => onClick(e)}
+
style="color: {isFull ? color : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
+
title={name}
+
>
+
<Icon icon={hasSolid && isFull ? `${icon}-solid` : icon} width={20} />
+
</button>
+
{/snippet}
+
<div class="mt-3 flex w-full items-center justify-between">
+
<div class="flex w-fit items-center rounded-sm" style="background: {color}1f;">
+
{#snippet label(
+
name: string,
+
icon: string,
+
onClick: (link: Backlink | null | undefined) => void,
+
backlink?: Backlink | null,
+
hasSolid?: boolean
+
)}
+
{@render control(name, icon, () => onClick(backlink), backlink ? true : false, hasSolid)}
+
{/snippet}
+
{@render label('reply', 'heroicons:chat-bubble-left', () => {
+
onReply?.(post);
+
})}
+
{@render label(
+
'repost',
+
'heroicons:arrow-path-rounded-square-20-solid',
+
async (link) => {
+
if (link === undefined) return;
+
postActions.set(`${selectedDid!}:${aturi}`, {
+
...backlinks!,
+
repost: await toggleLink(link, 'app.bsky.feed.repost')
+
});
+
},
+
backlinks?.repost
+
)}
+
{@render label('quote', 'heroicons:paper-clip-20-solid', () => {
+
onQuote?.(post);
+
})}
+
{@render label(
+
'like',
+
'heroicons:star',
+
async (link) => {
+
if (link === undefined) return;
+
postActions.set(`${selectedDid!}:${aturi}`, {
+
...backlinks!,
+
like: await toggleLink(link, 'app.bsky.feed.like')
+
});
+
},
+
backlinks?.like,
+
true
+
)}
+
</div>
+
<Dropdown
+
class="post-dropdown"
+
style="background: {color}36; border-color: {color}99;"
+
bind:isOpen={actionsOpen}
+
bind:position={actionsPos}
+
placement="bottom-end"
+
>
+
{@render dropdownItem('heroicons:link-20-solid', 'copy link to post', () =>
+
navigator.clipboard.writeText(`${$settings.socialAppUrl}/profile/${did}/post/${rkey}`)
+
)}
+
{@render dropdownItem('heroicons:link-20-solid', 'copy at uri', () =>
+
navigator.clipboard.writeText(post.uri)
+
)}
+
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
+
navigator.clipboard.writeText(post.record.text)
+
)}
+
{#if actionClient}
+
<div class="my-0.75 h-px w-full opacity-60" style="background: {color};"></div>
+
{@render dropdownItem(
+
deleteState === 'confirm' ? 'heroicons:check-20-solid' : 'heroicons:trash-20-solid',
+
deleteState === 'confirm' ? 'are you sure?' : 'delete post',
+
deletePost,
+
false,
+
deleteState === 'confirm' ? 'text-red-500' : ''
+
)}
+
{/if}
+
+
{#snippet trigger()}
+
<div
+
class="
+
w-fit items-center rounded-sm transition-opacity
+
duration-100 ease-in-out group-hover:opacity-100
+
{!actionsOpen && !Device.isMobile ? 'opacity-0' : ''}
+
"
+
style="background: {color}1f;"
+
>
+
{@render control('actions', 'heroicons:ellipsis-horizontal-16-solid', (e) => {
+
e.stopPropagation();
+
actionsOpen = !actionsOpen;
+
actionsPos = { x: 0, y: 0 };
+
})}
+
</div>
+
{/snippet}
+
</Dropdown>
+
</div>
+
{/snippet}
+
+
{#snippet dropdownItem(
+
icon: string,
+
label: string,
+
onClick: () => void,
+
autoClose: boolean = true,
+
extraClass: string = ''
+
)}
+
<button
+
class="
+
flex items-center justify-between rounded-sm px-2 py-1.5
+
transition-all duration-100 hover:[backdrop-filter:brightness(120%)]
+
{extraClass}
+
"
+
onclick={() => {
+
onClick();
+
if (autoClose) actionsOpen = false;
+
}}
+
>
+
<span class="font-bold">{label}</span>
+
<Icon class="h-6 w-6" {icon} />
+
</button>
+
{/snippet}
+
+
<style>
+
@reference "../app.css";
+
+
:global(.post-dropdown) {
+
@apply flex min-w-54 flex-col gap-1 rounded-sm border-2 p-1 shadow-2xl backdrop-blur-xl backdrop-brightness-60;
+
}
+
</style>
+106
src/components/Dropdown.svelte
···
+
<script lang="ts">
+
import {
+
computePosition,
+
autoUpdate,
+
offset,
+
flip,
+
shift,
+
type Placement
+
} from '@floating-ui/dom';
+
import { onMount } from 'svelte';
+
import { portal } from 'svelte-portal';
+
import type { ClassValue } from 'svelte/elements';
+
+
interface Props {
+
class?: ClassValue;
+
style?: string;
+
isOpen?: boolean;
+
trigger?: import('svelte').Snippet;
+
children?: import('svelte').Snippet;
+
placement?: Placement;
+
offsetDistance?: number;
+
position?: { x: number; y: number };
+
}
+
+
let {
+
isOpen = $bindable(false),
+
trigger,
+
children,
+
placement = 'bottom-start',
+
offsetDistance = 2,
+
position = $bindable(),
+
...restProps
+
}: Props = $props();
+
+
let triggerRef: HTMLElement | undefined = $state();
+
let contentRef: HTMLElement | undefined = $state();
+
let cleanup: (() => void) | null = null;
+
+
const updatePosition = async () => {
+
const { x, y } = await computePosition(triggerRef!, contentRef!, {
+
placement,
+
middleware: [offset(offsetDistance), flip(), shift({ padding: 8 })],
+
strategy: 'fixed'
+
});
+
+
Object.assign(contentRef!.style, {
+
left: `${x}px`,
+
top: `${y}px`
+
});
+
};
+
+
const handleClose = () => (isOpen = false);
+
+
const isEventInElement = (event: MouseEvent, element: HTMLElement) => {
+
let rect = element.getBoundingClientRect();
+
let x = event.clientX;
+
let y = event.clientY;
+
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+
};
+
+
const handleClickOutside = (event: MouseEvent) => {
+
if (!isOpen) return;
+
if (!isEventInElement(event, triggerRef!) && !isEventInElement(event, contentRef!))
+
handleClose();
+
};
+
+
const handleEscape = (event: KeyboardEvent) => {
+
if (event.key === 'Escape') handleClose();
+
};
+
+
const handleScroll = handleClose;
+
+
$effect(() => {
+
if (isOpen) {
+
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
+
} else if (cleanup) {
+
cleanup();
+
cleanup = null;
+
}
+
});
+
+
onMount(() => {
+
return () => {
+
if (cleanup) cleanup();
+
};
+
});
+
</script>
+
+
<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
+
+
<div role="button" tabindex="0" bind:this={triggerRef}>
+
{@render trigger?.()}
+
</div>
+
+
{#if isOpen}
+
<div
+
use:portal={'#app-root'}
+
bind:this={contentRef}
+
class="fixed z-9999 animate-fade-in-scale-fast overflow-hidden {restProps.class ?? ''}"
+
style={restProps.style}
+
role="menu"
+
tabindex="-1"
+
>
+
{@render children?.()}
+
</div>
+
{/if}
+30
src/components/NotificationsPopup.svelte
···
+
<script lang="ts">
+
import Popup from './Popup.svelte';
+
+
interface Props {
+
isOpen: boolean;
+
onClose: () => void;
+
}
+
+
let { isOpen = $bindable(false), onClose }: Props = $props();
+
+
const handleClose = () => {
+
onClose();
+
};
+
</script>
+
+
<Popup
+
bind:isOpen
+
onClose={handleClose}
+
title="notifications"
+
width="w-[42vmax] max-w-2xl"
+
height="60vh"
+
showHeaderDivider={true}
+
>
+
<div class="flex h-full items-center justify-center">
+
<div class="text-center">
+
<div class="mb-4 text-6xl opacity-50">๐Ÿšง</div>
+
<h3 class="text-xl font-bold opacity-80">todo</h3>
+
</div>
+
</div>
+
</Popup>
+21
src/components/PfpPlaceholder.svelte
···
+
<script lang="ts">
+
interface Props {
+
color: string;
+
size: number;
+
}
+
+
let { color, size }: Props = $props();
+
</script>
+
+
<svg
+
class="shrink-0 rounded-sm"
+
style="background: color-mix(in srgb, {color} 27%, transparent); color: {color}; width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
+
xmlns="http://www.w3.org/2000/svg"
+
width="24px"
+
height="24px"
+
viewBox="0 0 16 16"
+
><path
+
fill="currentColor"
+
d="M8 8a3 3 0 1 0 0-6a3 3 0 0 0 0 6m4.735 6c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139z"
+
/></svg
+
>
+107
src/components/Popup.svelte
···
+
<script lang="ts">
+
import type { Snippet } from 'svelte';
+
import { portal } from 'svelte-portal';
+
+
interface Props {
+
isOpen: boolean;
+
onClose?: () => void;
+
title: string;
+
width?: string;
+
height?: string;
+
padding?: string;
+
showHeaderDivider?: boolean;
+
headerActions?: Snippet;
+
children: Snippet;
+
footer?: Snippet;
+
}
+
+
let {
+
isOpen = $bindable(false),
+
onClose = () => (isOpen = false),
+
title,
+
width = 'w-full max-w-md',
+
height = 'auto',
+
padding = 'p-4',
+
showHeaderDivider = false,
+
headerActions,
+
children,
+
footer
+
}: Props = $props();
+
+
const handleKeydown = (event: KeyboardEvent) => {
+
if (event.key === 'Escape') onClose();
+
};
+
+
$effect(() => {
+
document.body.style.overflow = isOpen ? 'hidden' : 'auto';
+
});
+
</script>
+
+
{#if isOpen}
+
<div
+
use:portal={'#app-root'}
+
class="fixed inset-0 z-50 flex items-center justify-center bg-(--nucleus-bg)/80 backdrop-blur-sm"
+
onclick={onClose}
+
onkeydown={handleKeydown}
+
role="button"
+
tabindex="-1"
+
>
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
class="
+
flex {height === 'auto' ? '' : `h-[${height}]`} {width} shrink animate-fade-in-scale flex-col
+
rounded-sm border-2 border-(--nucleus-accent) bg-(--nucleus-bg) shadow-2xl transition-all
+
"
+
style={height !== 'auto' ? `height: ${height}` : ''}
+
onclick={(e) => e.stopPropagation()}
+
role="dialog"
+
>
+
<!-- Header -->
+
<div
+
class="flex items-center gap-4 {showHeaderDivider
+
? 'border-b-2 border-(--nucleus-accent)/20'
+
: ''} {padding}"
+
>
+
<div>
+
<h2 class="text-2xl font-bold">{title}</h2>
+
<div class="mt-2 flex gap-2">
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent)"></div>
+
<div class="h-1 w-9.5 rounded-full bg-(--nucleus-accent2)"></div>
+
</div>
+
</div>
+
+
{#if headerActions}
+
{@render headerActions()}
+
{/if}
+
+
<div class="grow"></div>
+
+
<!-- svelte-ignore a11y_consider_explicit_label -->
+
<button
+
onclick={onClose}
+
class="rounded-xl p-2 text-(--nucleus-fg)/40 transition-all hover:scale-110 hover:text-(--nucleus-fg)"
+
>
+
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+
<path
+
stroke-linecap="round"
+
stroke-linejoin="round"
+
stroke-width="2.5"
+
d="M6 18L18 6M6 6l12 12"
+
/>
+
</svg>
+
</button>
+
</div>
+
+
<!-- Content -->
+
<div class="{height === 'auto' ? '' : 'flex-1 overflow-y-auto'} {padding}">
+
{@render children()}
+
</div>
+
+
<!-- Footer -->
+
{#if footer}
+
{@render footer()}
+
{/if}
+
</div>
+
</div>
+
{/if}
+210 -28
src/components/PostComposer.svelte
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
-
import { ok, err, type Result } from '$lib/result';
-
import type { ComAtprotoRepoCreateRecord } from '@atcute/atproto';
+
import { ok, err, type Result, expect } from '$lib/result';
import type { AppBskyFeedPost } from '@atcute/bluesky';
-
import type { InferOutput } from '@atcute/lexicons';
+
import { generateColorForDid } from '$lib/accounts';
+
import type { PostWithUri } from '$lib/at/fetch';
+
import BskyPost from './BskyPost.svelte';
+
import { parseCanonicalResourceUri } from '@atcute/lexicons';
+
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
interface Props {
client: AtpClient;
+
onPostSent: (post: PostWithUri) => void;
+
quoting?: PostWithUri;
+
replying?: PostWithUri;
}
-
const { client }: Props = $props();
+
let {
+
client,
+
onPostSent,
+
quoting = $bindable(undefined),
+
replying = $bindable(undefined)
+
}: Props = $props();
+
+
let color = $derived(
+
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
+
);
-
const post = async (
-
text: string
-
): Promise<
-
Result<InferOutput<(typeof ComAtprotoRepoCreateRecord.mainSchema)['output']['schema']>, string>
-
> => {
+
const post = async (text: string): Promise<Result<PostWithUri, string>> => {
+
const strongRef = (p: PostWithUri): ComAtprotoRepoStrongRef.Main => ({
+
$type: 'com.atproto.repo.strongRef',
+
cid: p.cid!,
+
uri: p.uri
+
});
const record: AppBskyFeedPost.Main = {
$type: 'app.bsky.feed.post',
text,
+
reply: replying
+
? {
+
root: replying.record.reply?.root ?? strongRef(replying),
+
parent: strongRef(replying)
+
}
+
: undefined,
+
embed: quoting
+
? {
+
$type: 'app.bsky.embed.record',
+
record: strongRef(quoting)
+
}
+
: undefined,
createdAt: new Date().toISOString()
};
const res = await client.atcute?.post('com.atproto.repo.createRecord', {
input: {
collection: 'app.bsky.feed.post',
-
repo: client.didDoc!.did,
+
repo: client.user!.did,
record
}
});
···
return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
}
-
return ok(res.data);
+
return ok({
+
uri: res.data.uri,
+
cid: res.data.cid,
+
record
+
});
};
let postText = $state('');
let info = $state('');
+
let isFocused = $state(false);
+
let textareaEl: HTMLTextAreaElement | undefined = $state();
+
+
const unfocus = () => {
+
isFocused = false;
+
quoting = undefined;
+
replying = undefined;
+
};
+
+
const doPost = () => {
+
if (postText.length === 0 || postText.length > 300) return;
+
+
post(postText).then((res) => {
+
if (res.ok) {
+
onPostSent(res.value);
+
postText = '';
+
info = 'posted!';
+
unfocus();
+
setTimeout(() => (info = ''), 1000 * 0.8);
+
} else {
+
// todo: add a way to clear error
+
info = res.error;
+
}
+
});
+
};
+
+
$effect(() => {
+
document.documentElement.style.setProperty('--acc-color', color);
+
if (isFocused && textareaEl) textareaEl.focus();
+
if (quoting || replying) isFocused = true;
+
});
</script>
-
<div class="flex flex-col gap-0.5">
-
{#if info.length > 0}
-
<span class="text-sm text-gray-500">{info}</span>
-
{/if}
-
<div class="flex gap-2">
-
<input bind:value={postText} type="text" placeholder="write your post here..." />
+
{#snippet renderPost(post: PostWithUri)}
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
+
<BskyPost
+
{client}
+
did={parsedUri.repo}
+
rkey={parsedUri.rkey}
+
data={post}
+
isOnPostComposer={true}
+
/>
+
{/snippet}
+
+
{#snippet composer()}
+
<div class="flex items-center gap-2">
+
<div class="grow"></div>
+
<span
+
class="text-sm font-medium"
+
style="color: color-mix(in srgb, {postText.length > 300
+
? '#ef4444'
+
: 'var(--nucleus-fg)'} 53%, transparent);"
+
>
+
{postText.length} / 300
+
</span>
<button
-
onclick={() => {
-
post(postText).then((res) => {
-
if (res.ok) {
-
postText = '';
-
info = 'posted!';
-
setTimeout(() => (info = ''), 1000 * 3);
-
} else {
-
info = res.error;
-
}
-
});
-
}}>post</button
+
onmousedown={(e) => {
+
e.preventDefault();
+
doPost();
+
}}
+
disabled={postText.length === 0 || postText.length > 300}
+
class="action-button border-none px-5 text-(--nucleus-fg)/94 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100"
+
style="background: color-mix(in srgb, {color} 87%, transparent);"
>
+
post
+
</button>
+
</div>
+
{#if replying}
+
{@render renderPost(replying)}
+
{/if}
+
<div class="composer space-y-2">
+
<textarea
+
bind:this={textareaEl}
+
bind:value={postText}
+
onfocus={() => (isFocused = true)}
+
onblur={unfocus}
+
onkeydown={(event) => {
+
if (event.key === 'Escape') unfocus();
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) doPost();
+
}}
+
placeholder="what's on your mind?"
+
rows="4"
+
class="field-sizing-content resize-none"
+
></textarea>
+
{#if quoting}
+
{@render renderPost(quoting)}
+
{/if}
+
</div>
+
{/snippet}
+
+
<div class="relative min-h-13">
+
<!-- Spacer to maintain layout when focused -->
+
{#if isFocused}
+
<div class="min-h-13"></div>
+
{/if}
+
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
onmousedown={(e) => {
+
if (isFocused) {
+
e.preventDefault();
+
}
+
}}
+
class="flex max-w-full rounded-sm border-2 shadow-lg transition-all duration-300
+
{!isFocused ? 'min-h-13 items-center' : ''}
+
{isFocused ? 'absolute right-0 bottom-0 left-0 z-50 shadow-2xl' : ''}"
+
style="background: {isFocused
+
? `color-mix(in srgb, var(--nucleus-bg) 75%, ${color})`
+
: `color-mix(in srgb, color-mix(in srgb, var(--nucleus-bg) 85%, ${color}) 70%, transparent)`};
+
border-color: color-mix(in srgb, {color} {isFocused ? '100' : '40'}%, transparent);"
+
>
+
<div class="w-full p-1.5 px-2">
+
{#if info.length > 0}
+
<div
+
class="rounded-sm px-3 py-1.5 text-center font-medium text-nowrap overflow-ellipsis"
+
style="background: color-mix(in srgb, {color} 13%, transparent); color: {color};"
+
>
+
{info}
+
</div>
+
{:else}
+
<div class="flex flex-col gap-2">
+
{#if isFocused}
+
{@render composer()}
+
{:else}
+
<input
+
bind:value={postText}
+
onfocus={() => (isFocused = true)}
+
type="text"
+
placeholder="what's on your mind?"
+
class="flex-1"
+
/>
+
{/if}
+
</div>
+
{/if}
+
</div>
</div>
</div>
+
+
<!-- TODO: this fucking blows -->
+
<style>
+
@reference "../app.css";
+
+
input,
+
.composer {
+
@apply single-line-input bg-(--nucleus-bg)/35;
+
border-color: color-mix(in srgb, var(--acc-color) 30%, transparent);
+
}
+
+
.composer {
+
@apply p-2;
+
}
+
+
textarea {
+
@apply w-full bg-transparent p-0;
+
}
+
+
input {
+
@apply p-1 px-2;
+
}
+
+
.composer {
+
@apply focus:scale-100;
+
}
+
+
input::placeholder,
+
textarea::placeholder {
+
color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg));
+
}
+
+
textarea:focus {
+
@apply border-none! [box-shadow:none]! outline-none!;
+
}
+
</style>
+42
src/components/ProfilePicture.svelte
···
+
<script lang="ts">
+
import { generateColorForDid } from '$lib/accounts';
+
import type { AtpClient } from '$lib/at/client';
+
import { isBlob } from '@atcute/lexicons/interfaces';
+
import PfpPlaceholder from './PfpPlaceholder.svelte';
+
import { img } from '$lib/cdn';
+
import type { Did } from '@atcute/lexicons';
+
+
interface Props {
+
client: AtpClient;
+
did: Did;
+
size: number;
+
}
+
+
let { client, did, size }: Props = $props();
+
+
let color = $derived(generateColorForDid(did));
+
</script>
+
+
{#snippet missingPfp()}
+
<PfpPlaceholder {color} {size} />
+
{/snippet}
+
+
{#await client.getProfile(did)}
+
{@render missingPfp()}
+
{:then profile}
+
{#if profile.ok}
+
{@const record = profile.value}
+
{#if isBlob(record.avatar)}
+
<img
+
class="rounded-sm"
+
style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
+
alt="avatar for {did}"
+
src={img('avatar_thumbnail', did, record.avatar.ref.$link)}
+
/>
+
{:else}
+
{@render missingPfp()}
+
{/if}
+
{:else}
+
{@render missingPfp()}
+
{/if}
+
{/await}
+191
src/components/SettingsPopup.svelte
···
+
<script lang="ts">
+
import { defaultSettings, needsReload, settings } from '$lib/settings';
+
import { handleCache, didDocCache, recordCache } from '$lib/at/client';
+
import { get } from 'svelte/store';
+
import ColorPicker from 'svelte-awesome-color-picker';
+
import Popup from './Popup.svelte';
+
import Tabs from './Tabs.svelte';
+
+
interface Props {
+
isOpen: boolean;
+
onClose: () => void;
+
}
+
+
let { isOpen = $bindable(false), onClose }: Props = $props();
+
+
type Tab = 'style' | 'moderation' | 'advanced';
+
let activeTab = $state<Tab>('advanced');
+
+
let localSettings = $state(get(settings));
+
let hasReloadChanges = $derived(needsReload($settings, localSettings));
+
+
$effect(() => {
+
$settings.theme = localSettings.theme;
+
});
+
+
const resetSettingsToSaved = () => {
+
localSettings = $settings;
+
};
+
+
const handleClose = () => {
+
resetSettingsToSaved();
+
onClose();
+
};
+
+
const handleSave = () => {
+
settings.set(localSettings);
+
window.location.reload();
+
};
+
+
const handleReset = () => {
+
const confirmed = confirm('reset all settings to defaults?');
+
if (!confirmed) return;
+
settings.reset();
+
window.location.reload();
+
};
+
+
const handleClearCache = () => {
+
handleCache.clear();
+
didDocCache.clear();
+
recordCache.clear();
+
alert('cache cleared!');
+
};
+
</script>
+
+
{#snippet divider()}
+
<div class="h-px bg-linear-to-r from-(--nucleus-accent) to-(--nucleus-accent2)"></div>
+
{/snippet}
+
+
{#snippet settingHeader(name: string, desc: string)}
+
<h3 class="mb-3 text-lg font-bold">{name}</h3>
+
<p class="mb-4 text-sm opacity-80">{desc}</p>
+
{/snippet}
+
+
{#snippet advancedTab()}
+
<div class="space-y-5">
+
<div>
+
<h3 class="mb-3 text-lg font-bold">api endpoints</h3>
+
<div class="space-y-4">
+
{#snippet _input(name: string, desc: string)}
+
<div>
+
<label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
{desc}
+
</label>
+
<input
+
id={name}
+
type="url"
+
bind:value={localSettings.endpoints[name]}
+
placeholder={defaultSettings.endpoints[name]}
+
class="single-line-input"
+
/>
+
</div>
+
{/snippet}
+
{@render _input('slingshot', 'slingshot url (for fetching records & resolving identity)')}
+
{@render _input('spacedust', 'spacedust url (for notifications)')}
+
{@render _input('constellation', 'constellation url (for backlinks)')}
+
</div>
+
</div>
+
+
{@render divider()}
+
+
<div>
+
<label for="social-app-url" class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
social-app url (for when copying links to posts / profiles)
+
</label>
+
<input
+
id="social-app-url"
+
type="url"
+
bind:value={localSettings.socialAppUrl}
+
placeholder={defaultSettings.socialAppUrl}
+
class="single-line-input"
+
/>
+
</div>
+
+
{@render divider()}
+
+
<div>
+
{@render settingHeader(
+
'cache management',
+
'clears cached data (records, DID documents, handles, etc.)'
+
)}
+
<button onclick={handleClearCache} class="action-button"> clear cache </button>
+
</div>
+
+
{@render divider()}
+
+
<div>
+
{@render settingHeader('reset settings', 'resets all settings to their default values')}
+
<button
+
onclick={handleReset}
+
class="action-button border-red-600 text-red-600 hover:bg-red-600/20"
+
>
+
reset to defaults
+
</button>
+
</div>
+
</div>
+
{/snippet}
+
+
{#snippet styleTab()}
+
<div class="space-y-5">
+
<div>
+
<h3 class="mb-3 text-lg font-bold">colors</h3>
+
<div class="space-y-4">
+
{#snippet color(name: string, desc: string)}
+
<div>
+
<label for={name} class="mb-2 block text-sm font-semibold text-(--nucleus-fg)/80">
+
{desc}
+
</label>
+
<div class="color-picker">
+
<ColorPicker
+
bind:hex={localSettings.theme[name]}
+
isAlpha={false}
+
position="responsive"
+
label={localSettings.theme[name]}
+
/>
+
</div>
+
</div>
+
{/snippet}
+
{@render color('fg', 'foreground color')}
+
{@render color('bg', 'background color')}
+
{@render color('accent', 'accent color')}
+
{@render color('accent2', 'secondary accent color')}
+
</div>
+
</div>
+
</div>
+
{/snippet}
+
+
<Popup
+
bind:isOpen
+
onClose={handleClose}
+
title="settings"
+
width="w-[42vmax] max-w-2xl"
+
height="60vh"
+
showHeaderDivider={true}
+
>
+
{#snippet headerActions()}
+
{#if hasReloadChanges}
+
<button onclick={handleSave} class="shrink-0 action-button"> save & reload </button>
+
{/if}
+
{/snippet}
+
+
{#if activeTab === 'advanced'}
+
{@render advancedTab()}
+
{:else if activeTab === 'moderation'}
+
<div class="flex h-full items-center justify-center">
+
<div class="text-center">
+
<div class="mb-4 text-6xl opacity-50">๐Ÿšง</div>
+
<h3 class="text-xl font-bold opacity-80">todo</h3>
+
</div>
+
</div>
+
{:else if activeTab === 'style'}
+
{@render styleTab()}
+
{/if}
+
+
{#snippet footer()}
+
<Tabs
+
tabs={['style', 'moderation', 'advanced']}
+
bind:activeTab
+
onTabChange={(tab) => (activeTab = tab)}
+
/>
+
{/snippet}
+
</Popup>
+23
src/components/Tabs.svelte
···
+
<script lang="ts" generics="T extends string">
+
interface Props {
+
tabs: T[];
+
activeTab: T;
+
onTabChange: (tab: T) => void;
+
}
+
+
let { tabs, activeTab = $bindable(), onTabChange }: Props = $props();
+
</script>
+
+
<div class="flex">
+
{#each tabs as tab (tab)}
+
{@const isActive = activeTab === tab}
+
<button
+
onclick={() => onTabChange(tab)}
+
class="flex-1 border-t-3 px-4 py-3 font-semibold transition-colors hover:cursor-pointer {isActive
+
? 'border-(--nucleus-accent) bg-(--nucleus-accent)/20 text-(--nucleus-accent)'
+
: 'border-(--nucleus-accent)/20 bg-transparent text-(--nucleus-fg)/60 hover:bg-(--nucleus-accent)/10'}"
+
>
+
{tab}
+
</button>
+
{/each}
+
</div>
+41
src/lib/accounts.ts
···
+
import type { Handle } from '@atcute/lexicons';
+
import { writable } from 'svelte/store';
+
import { hashColor } from './theme';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
+
export type Account = {
+
did: AtprotoDid;
+
handle: Handle | null;
+
};
+
+
let _accounts: Account[] = [];
+
export const accounts = (() => {
+
const raw = localStorage.getItem('accounts');
+
_accounts = raw ? JSON.parse(raw) : [];
+
const store = writable<Account[]>(_accounts);
+
store.subscribe((accounts) => {
+
_accounts = accounts;
+
localStorage.setItem('accounts', JSON.stringify(accounts));
+
});
+
return store;
+
})();
+
+
export const addAccount = (account: Account): void => {
+
accounts.update((accounts) => [...accounts.filter((a) => a.did !== account.did), account]);
+
};
+
+
export const loggingIn = {
+
set: (account: Account | null) => {
+
if (!account) {
+
localStorage.removeItem('loggingIn');
+
} else {
+
localStorage.setItem('loggingIn', JSON.stringify(account));
+
}
+
},
+
get: (): Account | null => {
+
const raw = localStorage.getItem('loggingIn');
+
return raw ? JSON.parse(raw) : null;
+
}
+
};
+
+
export const generateColorForDid = (did: string) => hashColor(did);
+231 -81
src/lib/at/client.ts
···
-
import { err, map, ok, type Result } from '$lib/result';
-
import { ComAtprotoIdentityResolveIdentity, ComAtprotoRepoGetRecord } from '@atcute/atproto';
-
import { Client as AtcuteClient, CredentialManager } from '@atcute/client';
+
import { err, expect, map, ok, type Result } from '$lib/result';
+
import {
+
ComAtprotoIdentityResolveHandle,
+
ComAtprotoRepoGetRecord,
+
ComAtprotoRepoListRecords
+
} from '@atcute/atproto';
+
import { Client as AtcuteClient } from '@atcute/client';
import { safeParse, type Handle, type InferOutput } from '@atcute/lexicons';
-
import type { ActorIdentifier, AtprotoDid, Nsid, RecordKey } from '@atcute/lexicons/syntax';
+
import {
+
isDid,
+
parseCanonicalResourceUri,
+
parseResourceUri,
+
type ActorIdentifier,
+
type AtprotoDid,
+
type Cid,
+
type Did,
+
type Nsid,
+
type RecordKey,
+
type ResourceUri
+
} from '@atcute/lexicons/syntax';
import type {
+
InferInput,
InferXRPCBodyOutput,
ObjectSchema,
RecordKeySchema,
···
XRPCQueryMetadata
} from '@atcute/lexicons/validations';
import * as v from '@atcute/lexicons/validations';
-
import { LRUCache } from 'lru-cache';
-
-
export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', {
-
params: v.object({
-
identifier: v.actorIdentifierString()
-
}),
-
output: {
-
type: 'lex',
-
schema: v.object({
-
did: v.didString(),
-
handle: v.handleString(),
-
pds: v.genericUriString(),
-
signing_key: v.string()
-
})
-
}
-
});
-
export type MiniDoc = InferOutput<typeof MiniDocQuery.output.schema>;
+
import { MiniDocQuery, type MiniDoc } from './slingshot';
+
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
+
import type { Records } from '@atcute/lexicons/ambient';
+
import { PersistedLRU } from '$lib/cache';
+
import { AppBskyActorProfile } from '@atcute/bluesky';
+
import { WebSocket } from '@soffinal/websocket';
+
import type { Notification } from './stardust';
+
import { get } from 'svelte/store';
+
import { settings } from '$lib/settings';
+
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
+
// import { JetstreamSubscription } from '@atcute/jetstream';
const cacheTtl = 1000 * 60 * 60 * 24;
-
const handleCache = new LRUCache<Handle, AtprotoDid>({
+
export const handleCache = new PersistedLRU<Handle, AtprotoDid>({
max: 1000,
-
ttl: cacheTtl
+
ttl: cacheTtl,
+
prefix: 'handle'
});
-
const didDocCache = new LRUCache<ActorIdentifier, MiniDoc>({
+
export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({
max: 1000,
-
ttl: cacheTtl
+
ttl: cacheTtl,
+
prefix: 'didDoc'
});
-
const recordCache = new LRUCache<
+
export const recordCache = new PersistedLRU<
string,
InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema>
>({
max: 5000,
-
ttl: cacheTtl
+
ttl: cacheTtl,
+
prefix: 'record'
});
-
export class AtpClient {
-
public atcute: AtcuteClient | null = null;
-
public didDoc: MiniDoc | null = null;
+
export const slingshotUrl: URL = new URL(get(settings).endpoints.slingshot);
+
export const spacedustUrl: URL = new URL(get(settings).endpoints.spacedust);
+
export const constellationUrl: URL = new URL(get(settings).endpoints.constellation);
-
private slingshotUrl: URL = new URL('https://slingshot.microcosm.blue');
-
private spacedustUrl: URL = new URL('https://spacedust.microcosm.blue');
+
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
+
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
+
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
-
async login(handle: Handle, password: string): Promise<Result<null, string>> {
-
const didDoc = await this.resolveDidDoc(handle);
-
if (!didDoc.ok) return err(didDoc.error);
-
this.didDoc = didDoc.value;
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
+
export class AtpClient {
+
public atcute: AtcuteClient | null = null;
+
public user: { did: Did; handle: Handle } | null = null;
+
+
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
try {
-
const handler = new CredentialManager({ service: didDoc.value.pds });
-
const rpc = new AtcuteClient({ handler });
-
await handler.login({ identifier: didDoc.value.did, password });
-
+
const rpc = new AtcuteClient({ handler: agent });
+
const res = await rpc.get('com.atproto.server.getSession');
+
if (!res.ok) throw res.data.error;
+
this.user = {
+
did: res.data.did,
+
handle: res.data.handle
+
};
this.atcute = rpc;
} catch (error) {
return err(`failed to login: ${error}`);
···
return ok(null);
}
+
async getRecordUri<
+
Collection extends Nsid,
+
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
+
TKey extends RecordKeySchema,
+
Schema extends RecordSchema<TObject, TKey>,
+
Output extends InferInput<Schema>
+
>(schema: Schema, uri: ResourceUri): Promise<Result<RecordOutput<Output>, string>> {
+
const parsedUri = expect(parseResourceUri(uri));
+
if (parsedUri.collection !== schema.object.shape.$type.expected)
+
return err(
+
`collections don't match: ${parsedUri.collection} != ${schema.object.shape.$type.expected}`
+
);
+
return await this.getRecord(schema, parsedUri.repo!, parsedUri.rkey!);
+
}
+
async getRecord<
Collection extends Nsid,
TObject extends ObjectSchema & { shape: { $type: v.LiteralSchema<Collection> } },
TKey extends RecordKeySchema,
Schema extends RecordSchema<TObject, TKey>,
-
Output extends InferOutput<Schema>
-
>(schema: Schema, repo: ActorIdentifier, rkey: RecordKey): Promise<Result<Output, string>> {
+
Output extends InferInput<Schema>
+
>(
+
schema: Schema,
+
repo: ActorIdentifier,
+
rkey: RecordKey
+
): Promise<Result<RecordOutput<Output>, string>> {
const collection = schema.object.shape.$type.expected;
const cacheKey = `${repo}:${collection}:${rkey}`;
const cached = recordCache.get(cacheKey);
-
if (cached) return ok(cached.value as Output);
+
if (cached) return ok({ uri: cached.uri, cid: cached.cid, record: cached.value as Output });
+
const cachedSignal = recordCache.getSignal(cacheKey);
+
+
const result = await Promise.race([
+
fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
+
repo,
+
collection,
+
rkey
+
}).then((result): Result<RecordOutput<Output>, string> => {
+
if (!result.ok) return result;
+
+
const parsed = safeParse(schema, result.value.value);
+
if (!parsed.ok) return err(parsed.message);
+
+
recordCache.set(cacheKey, result.value);
-
const result = await fetchMicrocosm(this.slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
-
repo,
-
collection,
-
rkey
-
});
+
return ok({
+
uri: result.value.uri,
+
cid: result.value.cid,
+
record: parsed.value as Output
+
});
+
}),
+
cachedSignal.then(
+
(d): Result<RecordOutput<Output>, string> =>
+
ok({ uri: d.uri, cid: d.cid, record: d.value as Output })
+
)
+
]);
if (!result.ok) return result;
-
// console.info(`fetched record:`, result.value);
-
const parsed = safeParse(schema, result.value.value);
-
if (!parsed.ok) return err(parsed.message);
+
return ok(result.value);
+
}
-
recordCache.set(cacheKey, result.value);
+
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
+
repo = repo ?? this.user?.did;
+
if (!repo) return err('not authenticated');
+
return map(await this.getRecord(AppBskyActorProfile.mainSchema, repo, 'self'), (d) => d.record);
+
}
-
return ok(parsed.value as Output);
+
async listRecords<Collection extends keyof Records>(
+
collection: Collection,
+
repo: ActorIdentifier,
+
cursor?: string,
+
limit?: number
+
): Promise<
+
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
+
> {
+
if (!this.atcute) return err('not authenticated');
+
const res = await this.atcute.get('com.atproto.repo.listRecords', {
+
params: {
+
repo,
+
collection,
+
cursor,
+
limit
+
}
+
});
+
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
+
return ok(res.data);
}
-
async resolveHandle(handle: Handle): Promise<Result<AtprotoDid, string>> {
-
const cached = handleCache.get(handle);
+
async resolveHandle(identifier: ActorIdentifier): Promise<Result<AtprotoDid, string>> {
+
if (isDid(identifier)) return ok(identifier as AtprotoDid);
+
+
const cached = handleCache.get(identifier);
if (cached) return ok(cached);
+
const cachedSignal = handleCache.getSignal(identifier);
-
const res = await fetchMicrocosm(
-
this.slingshotUrl,
-
ComAtprotoIdentityResolveIdentity.mainSchema,
-
{
-
handle: handle
-
}
-
);
+
const res = await Promise.race([
+
fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
+
handle: identifier
+
}),
+
cachedSignal.then((d): Result<{ did: Did }, string> => ok({ did: d }))
+
]);
const mapped = map(res, (data) => data.did as AtprotoDid);
-
if (mapped.ok) {
-
handleCache.set(handle, mapped.value);
-
}
+
if (mapped.ok) handleCache.set(identifier, mapped.value);
return mapped;
}
···
async resolveDidDoc(handleOrDid: ActorIdentifier): Promise<Result<MiniDoc, string>> {
const cached = didDocCache.get(handleOrDid);
if (cached) return ok(cached);
+
const cachedSignal = didDocCache.getSignal(handleOrDid);
-
const result = await fetchMicrocosm(this.slingshotUrl, MiniDocQuery, {
-
identifier: handleOrDid
-
});
+
const result = await Promise.race([
+
fetchMicrocosm(slingshotUrl, MiniDocQuery, {
+
identifier: handleOrDid
+
}),
+
cachedSignal.then((d): Result<MiniDoc, string> => ok(d))
+
]);
-
if (result.ok) {
-
didDocCache.set(handleOrDid, result.value);
-
}
+
if (result.ok) didDocCache.set(handleOrDid, result.value);
return result;
}
+
+
async getBacklinksUri(
+
uri: ResourceUri,
+
source: BacklinksSource
+
): Promise<Result<Backlinks, string>> {
+
const parsedResourceUri = expect(parseCanonicalResourceUri(uri));
+
return await this.getBacklinks(
+
parsedResourceUri.repo,
+
parsedResourceUri.collection,
+
parsedResourceUri.rkey,
+
source
+
);
+
}
+
+
async getBacklinks(
+
repo: ActorIdentifier,
+
collection: Nsid,
+
rkey: RecordKey,
+
source: BacklinksSource
+
): Promise<Result<Backlinks, string>> {
+
const did = await this.resolveHandle(repo);
+
if (!did.ok) return err(`cant resolve handle: ${did.error}`);
+
+
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 2000));
+
const query = fetchMicrocosm(constellationUrl, BacklinksQuery, {
+
subject: `at://${did.value}/${collection}/${rkey}`,
+
source,
+
limit: 100
+
});
+
+
const results = await Promise.race([query, timeout]);
+
if (!results) return err('cant fetch backlinks: timeout');
+
+
return results;
+
}
+
+
streamNotifications(subjects: Did[], ...sources: BacklinksSource[]): NotificationsStream {
+
const url = new URL(spacedustUrl);
+
url.protocol = 'wss:';
+
url.pathname = '/subscribe';
+
const searchParams = [];
+
sources.every((source) => searchParams.push(['wantedSources', source]));
+
subjects.every((subject) => searchParams.push(['wantedSubjectDids', subject]));
+
subjects.every((subject) => searchParams.push(['wantedSubjects', `at://${subject}`]));
+
searchParams.push(['instant', 'true']);
+
url.search = `?${new URLSearchParams(searchParams)}`;
+
// console.log(`streaming notifications: ${url}`);
+
const encoder = WebSocket.getDefaultEncoder<undefined, Notification>();
+
const ws = new WebSocket<typeof encoder>(url.toString(), {
+
encoder
+
});
+
return ws;
+
}
+
+
// streamJetstream(subjects: Did[], ...collections: Nsid[]) {
+
// return new JetstreamSubscription({
+
// url: 'wss://jetstream2.fr.hose.cam',
+
// wantedCollections: collections,
+
// wantedDids: subjects
+
// });
+
// }
}
const fetchMicrocosm = async <
Schema extends XRPCQueryMetadata,
+
Input extends Schema['params'] extends ObjectSchema ? InferOutput<Schema['params']> : undefined,
Output extends InferXRPCBodyOutput<Schema['output']>
>(
api: URL,
schema: Schema,
-
params?: URLSearchParams | Record<string, string>,
+
params: Input,
init?: RequestInit
): Promise<Result<Output, string>> => {
if (!schema.output || schema.output.type === 'blob') return err('schema must be blob');
-
if (params && !(params instanceof URLSearchParams)) params = new URLSearchParams(params);
-
if (params?.size === 0) params = undefined;
+
api.pathname = `/xrpc/${schema.nsid}`;
+
api.search = params ? `?${new URLSearchParams(params)}` : '';
+
try {
+
const body = await fetchJson(api, init);
+
if (!body.ok) return err(body.error);
+
const parsed = safeParse(schema.output.schema, body.value);
+
if (!parsed.ok) return err(parsed.message);
+
return ok(parsed.value as Output);
+
} catch (error) {
+
return err(`FetchError: ${error}`);
+
}
+
};
+
+
const fetchJson = async (url: URL, init?: RequestInit): Promise<Result<unknown, string>> => {
try {
-
api.pathname = `/xrpc/${schema.nsid}`;
-
api.search = params ? `?${params}` : '';
-
// console.info(`fetching:`, api.href);
-
const response = await fetch(api, init);
+
const response = await fetch(url, init);
const body = await response.json();
if (response.status === 400) return err(`${body.error}: ${body.message}`);
if (response.status !== 200) return err(`UnexpectedStatusCode: ${response.status}`);
-
const parsed = safeParse(schema.output.schema, body);
-
if (!parsed.ok) return err(parsed.message);
-
return ok(parsed.value as Output);
+
return ok(body);
} catch (error) {
return err(`FetchError: ${error}`);
}
+27
src/lib/at/constellation.ts
···
+
import type { Nsid } from '@atcute/lexicons';
+
import * as v from '@atcute/lexicons/validations';
+
+
export type BacklinksSource = `${Nsid}:${string}`;
+
export const BacklinkSchema = v.object({
+
did: v.didString(),
+
collection: v.nsidString(),
+
rkey: v.recordKeyString()
+
});
+
export const BacklinksQuery = v.query('blue.microcosm.links.getBacklinks', {
+
params: v.object({
+
subject: v.resourceUriString(),
+
source: v.string(),
+
did: v.optional(v.array(v.didString())),
+
limit: v.optional(v.integer())
+
}),
+
output: {
+
type: 'lex',
+
schema: v.object({
+
total: v.integer(),
+
records: v.array(BacklinkSchema),
+
cursor: v.nullable(v.string())
+
})
+
}
+
});
+
export type Backlink = v.InferOutput<typeof BacklinkSchema>;
+
export type Backlinks = v.InferOutput<typeof BacklinksQuery.output.schema>;
+123
src/lib/at/fetch.ts
···
+
import {
+
parseCanonicalResourceUri,
+
type CanonicalResourceUri,
+
type Cid,
+
type ResourceUri
+
} from '@atcute/lexicons';
+
import { recordCache, type AtpClient } from './client';
+
import { err, expect, ok, type Result } from '$lib/result';
+
import type { Backlinks } from './constellation';
+
import { AppBskyFeedPost } from '@atcute/bluesky';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
+
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
+
export type PostWithBacklinks = PostWithUri & {
+
replies: Backlinks;
+
};
+
export type PostsWithReplyBacklinks = PostWithBacklinks[];
+
+
const replySource = 'app.bsky.feed.post:reply.parent.uri';
+
+
export const fetchPostsWithBacklinks = async (
+
client: AtpClient,
+
repo: AtprotoDid,
+
cursor?: string,
+
limit?: number
+
): Promise<Result<{ posts: PostsWithReplyBacklinks; cursor?: string }, string>> => {
+
const recordsList = await client.listRecords('app.bsky.feed.post', repo, cursor, limit);
+
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
+
cursor = recordsList.value.cursor;
+
const records = recordsList.value.records;
+
+
try {
+
const allBacklinks = await Promise.all(
+
records.map(async (r): Promise<PostWithBacklinks> => {
+
recordCache.set(r.uri, r);
+
const replies = await client.getBacklinksUri(r.uri, replySource);
+
if (!replies.ok) throw `cant fetch replies: ${replies.error}`;
+
return {
+
uri: r.uri,
+
cid: r.cid,
+
record: r.value as AppBskyFeedPost.Main,
+
replies: replies.value
+
};
+
})
+
);
+
return ok({ posts: allBacklinks, cursor });
+
} catch (error) {
+
return err(`cant fetch posts backlinks: ${error}`);
+
}
+
};
+
+
export const hydratePosts = async (
+
client: AtpClient,
+
repo: AtprotoDid,
+
data: PostsWithReplyBacklinks
+
): Promise<Result<Map<ResourceUri, PostWithUri>, string>> => {
+
let posts: Map<ResourceUri, PostWithUri> = new Map();
+
try {
+
const allPosts = await Promise.all(
+
data.map(async (post) => {
+
const result: PostWithUri[] = [post];
+
const replies = await Promise.all(
+
post.replies.records.map(async (r) => {
+
const reply = await client.getRecord(AppBskyFeedPost.mainSchema, r.did, r.rkey);
+
if (!reply.ok) throw `cant fetch reply: ${reply.error}`;
+
return reply.value;
+
})
+
);
+
result.push(...replies);
+
return result;
+
})
+
);
+
posts = new Map(allPosts.flat().map((post) => [post.uri, post]));
+
} catch (error) {
+
return err(`cant hydrate immediate replies: ${error}`);
+
}
+
+
const fetchUpwardsChain = async (post: PostWithUri) => {
+
let parent = post.record.reply?.parent;
+
while (parent) {
+
// if we already have this parent, then we already fetched this chain / are fetching it
+
if (posts.has(parent.uri as CanonicalResourceUri)) return;
+
const p = await client.getRecordUri(AppBskyFeedPost.mainSchema, parent.uri);
+
if (p.ok) {
+
posts.set(p.value.uri, p.value);
+
parent = p.value.record.reply?.parent;
+
continue;
+
}
+
// TODO: handle deleted parent posts
+
parent = undefined;
+
}
+
};
+
await Promise.all(posts.values().map(fetchUpwardsChain));
+
+
try {
+
const fetchDownwardsChain = async (post: PostWithUri) => {
+
const { repo: postRepo } = expect(parseCanonicalResourceUri(post.uri));
+
if (repo === postRepo) return;
+
+
// get chains that are the same author until we exhaust them
+
const backlinks = await client.getBacklinksUri(post.uri, replySource);
+
if (!backlinks.ok) return;
+
+
const promises = [];
+
for (const reply of backlinks.value.records) {
+
if (reply.did !== postRepo) continue;
+
// if we already have this reply, then we already fetched this chain / are fetching it
+
if (posts.has(`at://${reply.did}/${reply.collection}/${reply.rkey}`)) continue;
+
const record = await client.getRecord(AppBskyFeedPost.mainSchema, reply.did, reply.rkey);
+
if (!record.ok) break; // TODO: this doesnt handle deleted posts in between
+
posts.set(record.value.uri, record.value);
+
promises.push(fetchDownwardsChain(record.value));
+
}
+
+
await Promise.all(promises);
+
};
+
await Promise.all(posts.values().map(fetchDownwardsChain));
+
} catch (error) {
+
return err(`cant fetch post reply chain: ${error}`);
+
}
+
+
return ok(posts);
+
};
+91
src/lib/at/oauth.ts
···
+
import {
+
configureOAuth,
+
defaultIdentityResolver,
+
createAuthorizationUrl,
+
finalizeAuthorization,
+
OAuthUserAgent,
+
getSession,
+
deleteStoredSession
+
} from '@atcute/oauth-browser-client';
+
+
import {
+
CompositeDidDocumentResolver,
+
PlcDidDocumentResolver,
+
WebDidDocumentResolver,
+
XrpcHandleResolver
+
} from '@atcute/identity-resolver';
+
import { slingshotUrl } from './client';
+
import type { ActorIdentifier } from '@atcute/lexicons';
+
import { err, ok, type Result } from '$lib/result';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import { clientId, redirectUri } from '$lib/oauth';
+
+
configureOAuth({
+
metadata: {
+
client_id: clientId,
+
redirect_uri: redirectUri
+
},
+
identityResolver: defaultIdentityResolver({
+
handleResolver: new XrpcHandleResolver({ serviceUrl: slingshotUrl.href }),
+
+
didDocumentResolver: new CompositeDidDocumentResolver({
+
methods: {
+
plc: new PlcDidDocumentResolver(),
+
web: new WebDidDocumentResolver()
+
}
+
})
+
})
+
});
+
+
export const sessions = {
+
get: async (did: AtprotoDid) => {
+
const session = await getSession(did, { allowStale: true });
+
return new OAuthUserAgent(session);
+
},
+
remove: async (did: AtprotoDid) => {
+
try {
+
const agent = await sessions.get(did);
+
await agent.signOut();
+
} catch {
+
deleteStoredSession(did);
+
}
+
}
+
};
+
+
export const flow = {
+
start: async (identifier: ActorIdentifier): Promise<Result<null, string>> => {
+
try {
+
const authUrl = await createAuthorizationUrl({
+
target: { type: 'account', identifier },
+
scope: 'atproto transition:generic'
+
});
+
// recommended to wait for the browser to persist local storage before proceeding
+
await new Promise((resolve) => setTimeout(resolve, 200));
+
// redirect the user to sign in and authorize the app
+
window.location.assign(authUrl);
+
// if this is on an async function, ideally the function should never ever resolve.
+
// the only way it should resolve at this point is if the user aborted the authorization
+
// by returning back to this page (thanks to back-forward page caching)
+
await new Promise((_resolve, reject) => {
+
const listener = () => {
+
reject(new Error(`user aborted the login request`));
+
};
+
window.addEventListener('pageshow', listener, { once: true });
+
});
+
return ok(null);
+
} catch (error) {
+
return err(`login error: ${error}`);
+
}
+
},
+
finalize: async (url: URL): Promise<Result<OAuthUserAgent | null, string>> => {
+
try {
+
// createAuthorizationUrl asks server to put the params in the hash
+
const params = new URLSearchParams(url.hash.slice(1));
+
if (!params.has('code')) return ok(null);
+
const { session } = await finalizeAuthorization(params);
+
return ok(new OAuthUserAgent(session));
+
} catch (error) {
+
return err(`login error: ${error}`);
+
}
+
}
+
};
+17
src/lib/at/slingshot.ts
···
+
import * as v from '@atcute/lexicons/validations';
+
+
export const MiniDocQuery = v.query('com.bad-example.identity.resolveMiniDoc', {
+
params: v.object({
+
identifier: v.actorIdentifierString()
+
}),
+
output: {
+
type: 'lex',
+
schema: v.object({
+
did: v.didString(),
+
handle: v.handleString(),
+
pds: v.genericUriString(),
+
signing_key: v.string()
+
})
+
}
+
});
+
export type MiniDoc = v.InferOutput<typeof MiniDocQuery.output.schema>;
+16
src/lib/at/stardust.ts
···
+
import type { CanonicalResourceUri, RecordKey } from '@atcute/lexicons';
+
import type { BacklinksSource } from './constellation';
+
+
export type Notification = {
+
kind: 'link';
+
origin: string;
+
link: LinkNotification;
+
};
+
+
export type LinkNotification = {
+
operation: 'create'; // todo: delete in the future
+
source: BacklinksSource;
+
source_record: CanonicalResourceUri;
+
source_rev: RecordKey;
+
subject: CanonicalResourceUri;
+
};
+14
src/lib/at/types.ts
···
+
import type {
+
AppBskyEmbedExternal,
+
AppBskyEmbedImages,
+
AppBskyEmbedRecord,
+
AppBskyEmbedRecordWithMedia,
+
AppBskyEmbedVideo
+
} from '@atcute/bluesky';
+
+
export type AppBskyEmbeds =
+
| AppBskyEmbedExternal.Main
+
| AppBskyEmbedImages.Main
+
| AppBskyEmbedRecord.Main
+
| AppBskyEmbedRecordWithMedia.Main
+
| AppBskyEmbedVideo.Main;
+92
src/lib/cache.ts
···
+
import { Cache, type CacheOptions } from '@wora/cache-persist';
+
import { LRUCache } from 'lru-cache';
+
+
export interface PersistedLRUOptions {
+
prefix?: string;
+
max: number;
+
ttl?: number;
+
persistOptions?: CacheOptions;
+
}
+
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
+
export class PersistedLRU<K extends string, V extends {}> {
+
private memory: LRUCache<K, V>;
+
private storage: Cache;
+
private signals: Map<K, ((data: V) => void)[]>;
+
+
private prefix = '';
+
+
constructor(opts: PersistedLRUOptions) {
+
this.memory = new LRUCache<K, V>({
+
max: opts.max,
+
ttl: opts.ttl
+
});
+
this.storage = new Cache(opts.persistOptions);
+
this.prefix = opts.prefix ? `${opts.prefix}%` : '';
+
this.signals = new Map();
+
+
this.init();
+
}
+
+
async init(): Promise<void> {
+
await this.storage.restore();
+
+
const state = this.storage.getState();
+
for (const [key, val] of Object.entries(state)) {
+
try {
+
// console.log('restoring', key);
+
const k = this.unprefix(key) as unknown as K;
+
const v = val as V;
+
this.memory.set(k, v);
+
} catch (err) {
+
console.warn('skipping invalid persisted entry', key, err);
+
}
+
}
+
}
+
+
get(key: K): V | undefined {
+
return this.memory.get(key);
+
}
+
getSignal(key: K): Promise<V> {
+
return new Promise<V>((resolve) => {
+
if (!this.signals.has(key)) {
+
this.signals.set(key, [resolve]);
+
return;
+
}
+
const signals = this.signals.get(key)!;
+
signals.push(resolve);
+
this.signals.set(key, signals);
+
});
+
}
+
set(key: K, value: V): void {
+
this.memory.set(key, value);
+
this.storage.set(this.prefixed(key), value);
+
const signals = this.signals.get(key);
+
let signal = signals?.pop();
+
while (signal) {
+
signal(value);
+
signal = signals?.pop();
+
}
+
this.storage.flush(); // TODO: uh evil and fucked up (this whole file is evil honestly)
+
}
+
has(key: K): boolean {
+
return this.memory.has(key);
+
}
+
delete(key: K): void {
+
this.memory.delete(key);
+
this.storage.delete(this.prefixed(key));
+
this.storage.flush();
+
}
+
clear(): void {
+
this.memory.clear();
+
this.storage.purge();
+
this.storage.flush();
+
}
+
+
private prefixed(key: K): string {
+
return this.prefix + key;
+
}
+
private unprefix(prefixed: string): string {
+
return prefixed.slice(this.prefix.length);
+
}
+
}
+12
src/lib/cdn.ts
···
+
import type { Did } from '@atcute/lexicons';
+
+
export const cdn = `https://cdn.bsky.app`;
+
+
export type ImageKind = 'avatar_thumbnail' | 'avatar' | 'feed_thumbnail' | 'feed_fullsize';
+
export type ImageFormat = 'webp' | 'png' | 'jpg';
+
+
export const img = (kind: ImageKind, did: Did, blob: string, format: ImageFormat = 'webp') =>
+
`${cdn}/img/${kind}/plain/${did}/${blob}@${format}`;
+
+
export const blob = (pds: string, did: Did, cid: string) =>
+
`${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
+6
src/lib/domain.ts
···
+
import { dev } from '$app/environment';
+
import { env } from '$env/dynamic/public';
+
+
export const domain = dev ? 'http://127.0.0.1:5173' : env.PUBLIC_DOMAIN!;
+
+
export default domain;
-3
src/lib/index.ts
···
-
import { AtpClient } from './at/client';
-
-
export const client = new AtpClient();
+23
src/lib/oauth.ts
···
+
import domain from '$lib/domain';
+
import { dev } from '$app/environment';
+
+
export const oauthMetadata = {
+
client_id: `${domain}/oauth-client-metadata.json`,
+
client_name: 'nucleus',
+
client_uri: domain,
+
logo_uri: `${domain}/favicon.png`,
+
redirect_uris: [`${domain}/`],
+
scope: 'atproto transition:generic',
+
grant_types: ['authorization_code', 'refresh_token'],
+
response_types: ['code'],
+
token_endpoint_auth_method: 'none',
+
application_type: 'web',
+
dpop_bound_access_tokens: true
+
};
+
+
export const redirectUri = `${domain}/`;
+
export const clientId = dev
+
? `http://localhost` +
+
`?redirect_uri=${encodeURIComponent(redirectUri)}` +
+
`&scope=${encodeURIComponent(oauthMetadata.scope)}`
+
: oauthMetadata.client_id;
+71
src/lib/settings.ts
···
+
import { writable } from 'svelte/store';
+
import { defaultTheme, type Theme } from './theme';
+
+
export type ApiEndpoints = Record<string, string> & {
+
slingshot: string;
+
spacedust: string;
+
constellation: string;
+
};
+
export type Settings = {
+
endpoints: ApiEndpoints;
+
theme: Theme;
+
socialAppUrl: string;
+
};
+
+
export const defaultSettings: Settings = {
+
endpoints: {
+
slingshot: 'https://slingshot.microcosm.blue',
+
spacedust: 'https://spacedust.microcosm.blue',
+
constellation: 'https://constellation.microcosm.blue'
+
},
+
theme: defaultTheme,
+
socialAppUrl: 'https://bsky.app'
+
};
+
+
const createSettingsStore = () => {
+
const stored = localStorage.getItem('settings');
+
+
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
+
initial.endpoints = initial.endpoints ?? defaultSettings.endpoints;
+
initial.theme = initial.theme ?? defaultSettings.theme;
+
initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl;
+
+
const { subscribe, set, update } = writable<Settings>(initial as Settings);
+
+
subscribe((settings) => {
+
const theme = settings.theme;
+
document.documentElement.style.setProperty('--nucleus-bg', theme.bg);
+
document.documentElement.style.setProperty('--nucleus-fg', theme.fg);
+
document.documentElement.style.setProperty('--nucleus-accent', theme.accent);
+
document.documentElement.style.setProperty('--nucleus-accent2', theme.accent2);
+
});
+
+
return {
+
subscribe,
+
set: (value: Settings) => {
+
localStorage.setItem('settings', JSON.stringify(value));
+
set(value);
+
},
+
update: (fn: (value: Settings) => Settings) => {
+
update((value) => {
+
const newValue = fn(value);
+
localStorage.setItem('settings', JSON.stringify(newValue));
+
return newValue;
+
});
+
},
+
reset: () => {
+
localStorage.setItem('settings', JSON.stringify(defaultSettings));
+
set(defaultSettings);
+
}
+
};
+
};
+
+
export const settings = createSettingsStore();
+
+
export const needsReload = (current: Settings, other: Settings): boolean => {
+
return (
+
current.endpoints.slingshot !== other.endpoints.slingshot ||
+
current.endpoints.spacedust !== other.endpoints.spacedust ||
+
current.endpoints.constellation !== other.endpoints.constellation
+
);
+
};
+27
src/lib/state.svelte.ts
···
+
import { writable } from 'svelte/store';
+
import { AtpClient, type NotificationsStream } from './at/client';
+
import { SvelteMap } from 'svelte/reactivity';
+
import type { Did, ResourceUri } from '@atcute/lexicons';
+
import type { Backlink } from './at/constellation';
+
import type { PostWithUri } from './at/fetch';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
// import type { JetstreamSubscription } from '@atcute/jetstream';
+
+
export const notificationStream = writable<NotificationsStream | null>(null);
+
// export const jetstream = writable<JetstreamSubscription | null>(null);
+
+
export type PostActions = {
+
like: Backlink | null;
+
repost: Backlink | null;
+
// reply: Backlink | null;
+
// quote: Backlink | null;
+
};
+
export const postActions = new SvelteMap<`${Did}:${ResourceUri}`, PostActions>();
+
+
export const pulsingPostId = writable<string | null>(null);
+
+
export const viewClient = new AtpClient();
+
export const clients = new SvelteMap<AtprotoDid, AtpClient>();
+
+
export const posts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
+
export const cursors = new SvelteMap<Did, { value?: string; end: boolean }>();
+77
src/lib/theme.ts
···
+
export type Theme = Record<string, string> & {
+
bg: string;
+
fg: string;
+
accent: string;
+
accent2: string;
+
};
+
+
export const defaultTheme: Theme = {
+
bg: '#11001c',
+
fg: '#f8fafc',
+
accent: '#ec4899',
+
accent2: '#8b5cf6'
+
};
+
+
export const hashColor = (input: string): string => {
+
let hash: number;
+
+
const id = input.split(':').pop() || input;
+
+
hash = 0;
+
for (let i = 0; i < Math.min(10, id.length); i++) {
+
hash = (hash << 4) + id.charCodeAt(i);
+
}
+
hash = hash >>> 0;
+
+
// magic mixing
+
hash ^= hash >>> 16;
+
hash = Math.imul(hash, 0x21f0aaad);
+
hash ^= hash >>> 15;
+
hash = hash >>> 0;
+
+
const hue = hash % 360;
+
const saturation = 0.8 + ((hash >>> 10) % 20) * 0.01; // 80-100%
+
const lightness = 0.45 + ((hash >>> 20) % 35) * 0.01; // 45-80%
+
+
const rgb = hslToRgb(hue, saturation, lightness);
+
const hex = rgb.map((value) => value.toString(16).padStart(2, '0')).join('');
+
+
return `#${hex}`;
+
};
+
+
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
+
const c = (1 - Math.abs(2 * l - 1)) * s;
+
const hPrime = h / 60;
+
const x = c * (1 - Math.abs((hPrime % 2) - 1));
+
const m = l - c / 2;
+
+
let r: number, g: number, b: number;
+
+
if (hPrime < 1) {
+
r = c;
+
g = x;
+
b = 0;
+
} else if (hPrime < 2) {
+
r = x;
+
g = c;
+
b = 0;
+
} else if (hPrime < 3) {
+
r = 0;
+
g = c;
+
b = x;
+
} else if (hPrime < 4) {
+
r = 0;
+
g = x;
+
b = c;
+
} else if (hPrime < 5) {
+
r = x;
+
g = 0;
+
b = c;
+
} else {
+
r = c;
+
g = 0;
+
b = x;
+
}
+
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
+
};
+168
src/lib/thread.ts
···
+
import { parseCanonicalResourceUri, type Did, type ResourceUri } from '@atcute/lexicons';
+
import type { Account } from './accounts';
+
import { expect } from './result';
+
import type { PostWithUri } from './at/fetch';
+
+
export type ThreadPost = {
+
data: PostWithUri;
+
account: Did;
+
did: Did;
+
rkey: string;
+
parentUri: ResourceUri | null;
+
depth: number;
+
newestTime: number;
+
};
+
+
export type Thread = {
+
rootUri: ResourceUri;
+
posts: ThreadPost[];
+
newestTime: number;
+
branchParentPost?: ThreadPost;
+
};
+
+
export const buildThreads = (timelines: Map<Did, Map<ResourceUri, PostWithUri>>): Thread[] => {
+
const threadMap = new Map<ResourceUri, ThreadPost[]>();
+
+
// group posts by root uri into "thread" chains
+
for (const [account, timeline] of timelines) {
+
for (const [uri, data] of timeline) {
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
+
const rootUri = (data.record.reply?.root.uri as ResourceUri) || uri;
+
const parentUri = (data.record.reply?.parent.uri as ResourceUri) || null;
+
+
const post: ThreadPost = {
+
data,
+
account,
+
did: parsedUri.repo,
+
rkey: parsedUri.rkey,
+
parentUri,
+
depth: 0,
+
newestTime: new Date(data.record.createdAt).getTime()
+
};
+
+
if (!threadMap.has(rootUri)) threadMap.set(rootUri, []);
+
+
threadMap.get(rootUri)!.push(post);
+
}
+
}
+
+
const threads: Thread[] = [];
+
+
for (const [rootUri, posts] of threadMap) {
+
const uriToPost = new Map(posts.map((p) => [p.data.uri, p]));
+
const childrenMap = new Map<ResourceUri | null, ThreadPost[]>();
+
+
// calculate depths
+
for (const post of posts) {
+
let depth = 0;
+
let currentUri = post.parentUri;
+
+
while (currentUri && uriToPost.has(currentUri)) {
+
depth++;
+
currentUri = uriToPost.get(currentUri)!.parentUri;
+
}
+
+
post.depth = depth;
+
+
if (!childrenMap.has(post.parentUri)) childrenMap.set(post.parentUri, []);
+
childrenMap.get(post.parentUri)!.push(post);
+
}
+
+
childrenMap
+
.values()
+
.forEach((children) => children.sort((a, b) => b.newestTime - a.newestTime));
+
+
const createThread = (
+
posts: ThreadPost[],
+
rootUri: ResourceUri,
+
branchParentUri?: ResourceUri
+
): Thread => {
+
return {
+
rootUri,
+
posts,
+
newestTime: Math.max(...posts.map((p) => p.newestTime)),
+
branchParentPost: branchParentUri ? uriToPost.get(branchParentUri) : undefined
+
};
+
};
+
+
const collectSubtree = (startPost: ThreadPost): ThreadPost[] => {
+
const result: ThreadPost[] = [];
+
const addWithChildren = (post: ThreadPost) => {
+
result.push(post);
+
const children = childrenMap.get(post.data.uri) || [];
+
children.forEach(addWithChildren);
+
};
+
addWithChildren(startPost);
+
return result;
+
};
+
+
// find posts with >2 children to split them into separate chains
+
const branchingPoints = Array.from(childrenMap.entries())
+
.filter(([, children]) => children.length > 1)
+
.map(([uri]) => uri);
+
+
if (branchingPoints.length === 0) {
+
const roots = childrenMap.get(null) || [];
+
const allPosts = roots.flatMap((root) => collectSubtree(root));
+
threads.push(createThread(allPosts, rootUri));
+
} else {
+
for (const branchParentUri of branchingPoints) {
+
const branches = childrenMap.get(branchParentUri) || [];
+
+
const sortedBranches = [...branches].sort((a, b) => a.newestTime - b.newestTime);
+
+
sortedBranches.forEach((branchRoot, index) => {
+
const isOldestBranch = index === 0;
+
const branchPosts: ThreadPost[] = [];
+
+
// the oldest branch has the full context
+
// todo: consider letting the user decide this..?
+
if (isOldestBranch && branchParentUri !== null) {
+
const parentChain: ThreadPost[] = [];
+
let currentUri: ResourceUri | null = branchParentUri;
+
while (currentUri && uriToPost.has(currentUri)) {
+
parentChain.unshift(uriToPost.get(currentUri)!);
+
currentUri = uriToPost.get(currentUri)!.parentUri;
+
}
+
branchPosts.push(...parentChain);
+
}
+
+
branchPosts.push(...collectSubtree(branchRoot));
+
+
const minDepth = Math.min(...branchPosts.map((p) => p.depth));
+
branchPosts.forEach((p) => (p.depth = p.depth - minDepth));
+
+
threads.push(
+
createThread(
+
branchPosts,
+
branchRoot.data.uri,
+
isOldestBranch ? undefined : (branchParentUri ?? undefined)
+
)
+
);
+
});
+
}
+
}
+
}
+
+
threads.sort((a, b) => b.newestTime - a.newestTime);
+
+
// console.log(threads);
+
+
return threads;
+
};
+
+
export const isOwnPost = (post: ThreadPost, accounts: Account[]) =>
+
accounts.some((account) => account.did === post.did);
+
export const hasNonOwnPost = (posts: ThreadPost[], accounts: Account[]) =>
+
posts.some((post) => !isOwnPost(post, accounts));
+
+
// todo: add more filtering options
+
export type FilterOptions = {
+
viewOwnPosts: boolean;
+
};
+
+
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
+
threads.filter((thread) => {
+
if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
+
return true;
+
});
+6 -1
src/routes/+layout.svelte
···
<link rel="icon" href={favicon} />
</svelte:head>
-
{@render children?.()}
+
<div
+
id="app-root"
+
class="min-h-screen bg-(--nucleus-bg) text-(--nucleus-fg) transition-colors duration-300"
+
>
+
{@render children?.()}
+
</div>
+3
src/routes/+layout.ts
···
+
export const ssr = false;
+
export const prerender = true;
+
export const csr = true;
+483 -5
src/routes/+page.svelte
···
<script lang="ts">
import BskyPost from '$components/BskyPost.svelte';
import PostComposer from '$components/PostComposer.svelte';
-
import { client } from '$lib';
+
import AccountSelector from '$components/AccountSelector.svelte';
+
import SettingsPopup from '$components/SettingsPopup.svelte';
+
import { AtpClient, type NotificationsStreamEvent } from '$lib/at/client';
+
import { accounts, type Account } from '$lib/accounts';
+
import { type Did, parseCanonicalResourceUri, type ResourceUri } from '@atcute/lexicons';
+
import { onMount } from 'svelte';
+
import { fetchPostsWithBacklinks, hydratePosts, type PostWithUri } from '$lib/at/fetch';
+
import { expect } from '$lib/result';
+
import { AppBskyFeedPost } from '@atcute/bluesky';
+
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
+
import { clients, cursors, notificationStream, posts, viewClient } from '$lib/state.svelte';
+
import { get } from 'svelte/store';
+
import Icon from '@iconify/svelte';
+
import { sessions } from '$lib/at/oauth';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import type { PageProps } from './+page';
+
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
+
import NotificationsPopup from '$components/NotificationsPopup.svelte';
+
+
const { data: loadData }: PageProps = $props();
+
+
let errors = $state(loadData.client.ok ? [] : [loadData.client.error]);
+
let errorsOpen = $state(false);
+
+
let selectedDid = $state((localStorage.getItem('selectedDid') ?? null) as AtprotoDid | null);
+
$effect(() => {
+
if (selectedDid) {
+
localStorage.setItem('selectedDid', selectedDid);
+
} else {
+
localStorage.removeItem('selectedDid');
+
}
+
});
+
+
const selectedClient = $derived(selectedDid ? clients.get(selectedDid) : null);
+
+
const loginAccount = async (account: Account) => {
+
if (clients.has(account.did)) return;
+
const client = new AtpClient();
+
const result = await client.login(await sessions.get(account.did));
+
if (!result.ok) {
+
errors.push(`failed to login into @${account.handle ?? account.did}: ${result.error}`);
+
return;
+
}
+
clients.set(account.did, client);
+
};
+
+
const handleAccountSelected = async (did: AtprotoDid) => {
+
selectedDid = did;
+
const account = $accounts.find((acc) => acc.did === did);
+
if (account && (!clients.has(account.did) || !clients.get(account.did)?.atcute))
+
await loginAccount(account);
+
};
+
+
const handleLogout = async (did: AtprotoDid) => {
+
await sessions.remove(did);
+
const newAccounts = $accounts.filter((acc) => acc.did !== did);
+
$accounts = newAccounts;
+
clients.delete(did);
+
posts.delete(did);
+
cursors.delete(did);
+
handleAccountSelected(newAccounts[0]?.did);
+
};
+
+
let isSettingsOpen = $state(false);
+
let isNotificationsOpen = $state(false);
+
let reverseChronological = $state(true);
+
let viewOwnPosts = $state(true);
+
+
const threads = $derived(filterThreads(buildThreads(posts), $accounts, { viewOwnPosts }));
+
+
let quoting = $state<PostWithUri | undefined>(undefined);
+
let replying = $state<PostWithUri | undefined>(undefined);
+
+
const expandedThreads = new SvelteSet<ResourceUri>();
+
+
const addPosts = (did: Did, accTimeline: Map<ResourceUri, PostWithUri>) => {
+
if (!posts.has(did)) {
+
posts.set(did, new SvelteMap(accTimeline));
+
return;
+
}
+
const map = posts.get(did)!;
+
for (const [uri, record] of accTimeline) map.set(uri, record);
+
};
+
+
const fetchTimeline = async (account: Account) => {
+
const client = clients.get(account.did);
+
if (!client) return;
+
+
const cursor = cursors.get(account.did);
+
if (cursor && cursor.end) return;
+
+
const accPosts = await fetchPostsWithBacklinks(client, account.did, cursor?.value, 6);
+
if (!accPosts.ok) throw `cant fetch posts @${account.handle}: ${accPosts.error}`;
+
+
// if the cursor is undefined, we've reached the end of the timeline
+
if (!accPosts.value.cursor) {
+
cursors.set(account.did, { ...cursor, end: true });
+
return;
+
}
+
+
cursors.set(account.did, { value: accPosts.value.cursor, end: false });
+
const hydrated = await hydratePosts(client, account.did, accPosts.value.posts);
+
if (!hydrated.ok) throw `cant hydrate posts @${account.handle}: ${hydrated.error}`;
+
+
addPosts(account.did, hydrated.value);
+
};
+
+
const fetchTimelines = (newAccounts: Account[]) => Promise.all(newAccounts.map(fetchTimeline));
+
+
const handleNotification = async (event: NotificationsStreamEvent) => {
+
if (event.type === 'message') {
+
// console.log(event.data);
+
const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
+
const subjectPost = await viewClient.getRecord(
+
AppBskyFeedPost.mainSchema,
+
parsedSubjectUri.repo,
+
parsedSubjectUri.rkey
+
);
+
if (!subjectPost.ok) return;
+
+
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
+
const hydrated = await hydratePosts(viewClient, parsedSubjectUri.repo as AtprotoDid, [
+
{
+
record: subjectPost.value.record,
+
uri: event.data.link.subject,
+
cid: subjectPost.value.cid,
+
replies: {
+
cursor: null,
+
total: 1,
+
records: [
+
{
+
did: parsedSourceUri.repo,
+
collection: parsedSourceUri.collection,
+
rkey: parsedSourceUri.rkey
+
}
+
]
+
}
+
}
+
]);
+
+
if (!hydrated.ok) {
+
errors.push(`cant hydrate posts @${parsedSubjectUri.repo}: ${hydrated.error}`);
+
return;
+
}
+
+
// console.log(hydrated);
+
addPosts(parsedSubjectUri.repo, hydrated.value);
+
}
+
};
+
+
// const handleJetstream = async (subscription: JetstreamSubscription) => {
+
// for await (const event of subscription) {
+
// if (event.kind !== 'commit') continue;
+
// const commit = event.commit;
+
// if (commit.operation === 'delete') {
+
// continue;
+
// }
+
// const record = commit.record as AppBskyFeedPost.Main;
+
// addPosts(
+
// event.did,
+
// new Map([[`at://${event.did}/${commit.collection}/${commit.rkey}` as ResourceUri, record]])
+
// );
+
// }
+
// };
+
+
const loaderState = new LoaderState();
+
let scrollContainer = $state<HTMLDivElement>();
+
+
let loading = $state(false);
+
let loadError = $state('');
+
let showScrollToTop = $state(false);
+
+
const handleScroll = () => {
+
showScrollToTop = window.scrollY > 300;
+
};
+
+
const scrollToTop = () => {
+
window.scrollTo({ top: 0, behavior: 'smooth' });
+
};
+
+
const loadMore = async () => {
+
if (loading || $accounts.length === 0) return;
+
+
loading = true;
+
loaderState.status = 'LOADING';
+
+
try {
+
await fetchTimelines($accounts);
+
loaderState.loaded();
+
} catch (error) {
+
loadError = `${error}`;
+
loaderState.error();
+
loading = false;
+
return;
+
}
+
+
loading = false;
+
if (cursors.values().every((cursor) => cursor.end)) loaderState.complete();
+
};
+
+
onMount(() => {
+
window.addEventListener('scroll', handleScroll);
+
+
accounts.subscribe((newAccounts) => {
+
get(notificationStream)?.stop();
+
// jetstream.set(null);
+
if (newAccounts.length === 0) return;
+
notificationStream.set(
+
viewClient.streamNotifications(
+
newAccounts.map((account) => account.did),
+
'app.bsky.feed.post:reply.parent.uri'
+
)
+
);
+
// jetstream.set(
+
// viewClient.streamJetstream(
+
// newAccounts.map((account) => account.did),
+
// 'app.bsky.feed.post'
+
// )
+
// );
+
});
+
notificationStream.subscribe((stream) => {
+
if (!stream) return;
+
stream.listen(handleNotification);
+
});
+
// jetstream.subscribe((stream) => {
+
// if (!stream) return;
+
// handleJetstream(stream);
+
// });
+
if ($accounts.length > 0) {
+
loaderState.status = 'LOADING';
+
if (loadData.client.ok && loadData.client.value) {
+
const loggedInDid = loadData.client.value.user!.did as AtprotoDid;
+
selectedDid = loggedInDid;
+
clients.set(loggedInDid, loadData.client.value);
+
}
+
if (!$accounts.some((account) => account.did === selectedDid)) selectedDid = $accounts[0].did;
+
console.log('onMount selectedDid', selectedDid);
+
Promise.all($accounts.map(loginAccount)).then(() => {
+
loadMore();
+
});
+
} else {
+
selectedDid = null;
+
}
+
+
return () => {
+
window.removeEventListener('scroll', handleScroll);
+
};
+
});
</script>
-
<div class="flex flex-col gap-4">
-
<PostComposer {client} />
-
<hr />
-
<BskyPost {client} identifier="nil.ptr.pet" rkey="3m3d5zguuxk2c" />
+
{#snippet appButton(onClick: () => void, icon: string, ariaLabel: string, iconHover?: string)}
+
<button
+
onclick={onClick}
+
class="group rounded-sm bg-(--nucleus-accent)/15 p-2 text-(--nucleus-accent) transition-all hover:scale-110 hover:shadow-lg"
+
aria-label={ariaLabel}
+
>
+
<Icon class="group-hover:hidden" {icon} width={28} />
+
<Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} />
+
</button>
+
{/snippet}
+
+
<div class="mx-auto max-w-2xl">
+
<!-- thread list (page scrolls as a whole) -->
+
<div
+
id="app-thread-list"
+
class="mb-4 min-h-screen p-2 [scrollbar-color:var(--nucleus-accent)_transparent]"
+
bind:this={scrollContainer}
+
>
+
{#if $accounts.length > 0}
+
{@render renderThreads()}
+
{:else}
+
<div class="flex justify-center py-4">
+
<p class="text-xl opacity-80">
+
<span class="text-4xl">x_x</span> <br /> no accounts are logged in!
+
</p>
+
</div>
+
{/if}
+
</div>
+
+
<!-- header -->
+
<div class="sticky bottom-0 z-10">
+
{#if errors.length > 0}
+
<div class="relative m-3 mb-1 error-disclaimer">
+
<div class="flex items-center gap-2 text-red-500">
+
<Icon class="inline h-10 w-10" icon="heroicons:exclamation-triangle-16-solid" />
+
there are ({errors.length}) errors
+
<div class="grow"></div>
+
<button onclick={() => (errorsOpen = !errorsOpen)} class="action-button p-1 px-1.5"
+
>{errorsOpen ? 'hide details' : 'see details'}</button
+
>
+
</div>
+
{#if errorsOpen}
+
<div
+
class="absolute right-0 bottom-full left-0 z-10 mb-2 flex animate-fade-in-scale-fast flex-col gap-1 error-disclaimer shadow-lg transition-all"
+
>
+
{#each errors as error, idx (idx)}
+
<p>โ€ข {error}</p>
+
{/each}
+
</div>
+
{/if}
+
</div>
+
{/if}
+
+
<div
+
class="rounded-t-sm px-0.5 pt-0.5"
+
style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
+
>
+
<div
+
class="rounded-t-sm"
+
style="
+
background: linear-gradient(to right, color-mix(in srgb, var(--nucleus-accent) 18%, var(--nucleus-bg)), color-mix(in srgb, var(--nucleus-accent2) 13%, var(--nucleus-bg)));
+
"
+
>
+
<!-- composer and error disclaimer (above thread list, not scrollable) -->
+
<div class="flex gap-2 px-2 pt-2 pb-1">
+
<AccountSelector
+
client={viewClient}
+
accounts={$accounts}
+
bind:selectedDid
+
onAccountSelected={handleAccountSelected}
+
onLogout={handleLogout}
+
/>
+
+
{#if selectedClient}
+
<div class="flex-1">
+
<PostComposer
+
client={selectedClient}
+
onPostSent={(post) => posts.get(selectedDid!)?.set(post.uri, post)}
+
bind:quoting
+
bind:replying
+
/>
+
</div>
+
{:else}
+
<div
+
class="flex flex-1 items-center justify-center rounded-sm border-2 border-(--nucleus-accent)/20 bg-(--nucleus-accent)/4 px-4 py-2.5 backdrop-blur-sm"
+
>
+
<p class="text-sm opacity-80">select or add an account to post</p>
+
</div>
+
{/if}
+
+
{#if showScrollToTop}
+
{@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top')}
+
{/if}
+
</div>
+
+
<div
+
class="mt-1 h-px w-full opacity-50"
+
style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
+
></div>
+
+
<div class="flex items-center gap-1.5 px-2 py-1">
+
<div class="mb-2">
+
<h1 class="text-3xl font-bold tracking-tight">nucleus</h1>
+
<div class="mt-1 flex gap-2">
+
<div class="h-1 w-11 rounded-full bg-(--nucleus-accent)"></div>
+
<div class="h-1 w-8 rounded-full bg-(--nucleus-accent2)"></div>
+
</div>
+
</div>
+
<div class="grow"></div>
+
{@render appButton(
+
() => (isNotificationsOpen = true),
+
'heroicons:bell',
+
'notifications',
+
'heroicons:bell-solid'
+
)}
+
{@render appButton(
+
() => (isSettingsOpen = true),
+
'heroicons:cog-6-tooth',
+
'settings',
+
'heroicons:cog-6-tooth-solid'
+
)}
+
</div>
+
+
<!-- <hr
+
class="h-[4px] w-full rounded-full border-0"
+
style="background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));"
+
/> -->
+
</div>
+
</div>
+
</div>
</div>
+
+
<SettingsPopup bind:isOpen={isSettingsOpen} onClose={() => (isSettingsOpen = false)} />
+
<NotificationsPopup
+
bind:isOpen={isNotificationsOpen}
+
onClose={() => (isNotificationsOpen = false)}
+
/>
+
+
{#snippet replyPost(post: ThreadPost, reverse: boolean = reverseChronological)}
+
<span
+
class="mb-1.5 flex items-center gap-1.5 overflow-hidden text-nowrap wrap-break-word overflow-ellipsis"
+
>
+
<span class="text-sm text-nowrap opacity-60">{reverse ? 'โ†ฑ' : 'โ†ณ'}</span>
+
<BskyPost mini client={selectedClient ?? viewClient} {...post} />
+
</span>
+
{/snippet}
+
+
{#snippet threadsView()}
+
{#each threads as thread (thread.rootUri)}
+
<div class="flex w-full shrink-0 {reverseChronological ? 'flex-col' : 'flex-col-reverse'}">
+
{#if thread.branchParentPost}
+
{@render replyPost(thread.branchParentPost)}
+
{/if}
+
{#each thread.posts as post, idx (post.data.uri)}
+
{@const mini =
+
!expandedThreads.has(thread.rootUri) &&
+
thread.posts.length > 4 &&
+
idx > 0 &&
+
idx < thread.posts.length - 2}
+
{#if !mini}
+
<div class="mb-1.5">
+
<BskyPost
+
client={selectedClient ?? viewClient}
+
onQuote={(post) => (quoting = post)}
+
onReply={(post) => (replying = post)}
+
{...post}
+
/>
+
</div>
+
{:else if mini}
+
{#if idx === 1}
+
{@render replyPost(post, !reverseChronological)}
+
<button
+
class="mx-1.5 mt-1.5 mb-2.5 flex items-center gap-1.5 text-[color-mix(in_srgb,var(--nucleus-fg)_50%,var(--nucleus-accent))]/70 transition-colors hover:text-(--nucleus-accent)"
+
onclick={() => expandedThreads.add(thread.rootUri)}
+
>
+
<div class="mr-1 h-px w-20 rounded border-y-2 border-dashed opacity-50"></div>
+
<Icon
+
class="shrink-0"
+
icon={reverseChronological
+
? 'heroicons:bars-arrow-up-solid'
+
: 'heroicons:bars-arrow-down-solid'}
+
width={32}
+
/><span class="shrink-0 pb-1">view full chain</span>
+
<div class="ml-1 h-px w-full rounded border-y-2 border-dashed opacity-50"></div>
+
</button>
+
{:else if idx === thread.posts.length - 3}
+
{@render replyPost(post)}
+
{/if}
+
{/if}
+
{/each}
+
</div>
+
<div
+
class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
+
></div>
+
{/each}
+
{/snippet}
+
+
{#snippet renderThreads()}
+
<InfiniteLoader
+
{loaderState}
+
triggerLoad={loadMore}
+
loopDetectionTimeout={0}
+
intersectionOptions={{ root: scrollContainer }}
+
>
+
{@render threadsView()}
+
{#snippet noData()}
+
<div class="flex justify-center py-4">
+
<p class="text-xl opacity-80">
+
all posts seen! <span class="text-2xl">:o</span>
+
</p>
+
</div>
+
{/snippet}
+
{#snippet loading()}
+
<div class="flex justify-center">
+
<div
+
class="h-12 w-12 animate-spin rounded-full border-4 border-t-transparent"
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
+
></div>
+
</div>
+
{/snippet}
+
{#snippet error()}
+
<div class="flex flex-col gap-4 py-4">
+
<p class="text-xl opacity-80">
+
<span class="text-4xl">x_x</span> <br />
+
{loadError}
+
</p>
+
<div>
+
<button class="flex action-button items-center gap-2" onclick={loadMore}>
+
<Icon class="h-6 w-6" icon="heroicons:arrow-path-16-solid" /> try again
+
</button>
+
</div>
+
</div>
+
{/snippet}
+
</InfiniteLoader>
+
{/snippet}
+45
src/routes/+page.ts
···
+
import { replaceState } from '$app/navigation';
+
import { addAccount, loggingIn } from '$lib/accounts';
+
import { AtpClient } from '$lib/at/client';
+
import { flow, sessions } from '$lib/at/oauth';
+
import { err, ok, type Result } from '$lib/result';
+
import type { PageLoad } from './$types';
+
+
export type PageProps = {
+
data: {
+
client: Result<AtpClient | null, string>;
+
};
+
};
+
+
export const load: PageLoad = async (): Promise<PageProps['data']> => {
+
return { client: await handleLogin() };
+
};
+
+
const handleLogin = async (): Promise<Result<AtpClient | null, string>> => {
+
const account = loggingIn.get();
+
if (!account) return ok(null);
+
+
const currentUrl = new URL(window.location.href);
+
// scrub history so auth state cant be replayed
+
try {
+
replaceState('', '/');
+
} catch {
+
// if router was unitialized then we probably dont need to scrub anyway
+
// so its fine
+
}
+
+
loggingIn.set(null);
+
await sessions.remove(account.did);
+
const agent = await flow.finalize(currentUrl);
+
if (!agent.ok || !agent.value) {
+
if (!agent.ok) return err(agent.error);
+
return err('no session was logged into?!');
+
}
+
+
const client = new AtpClient();
+
const result = await client.login(agent.value);
+
if (!result.ok) return err(result.error);
+
+
addAccount(account);
+
return ok(client);
+
};
+13
src/routes/oauth-client-metadata.json/+server.ts
···
+
import { clientId, oauthMetadata } from '$lib/oauth';
+
import { domain } from '$lib/domain';
+
import { json } from '@sveltejs/kit';
+
+
export const prerender = true;
+
+
export const GET = () => {
+
return json({
+
...oauthMetadata,
+
client_id: clientId,
+
client_uri: domain
+
});
+
};
+1 -1
svelte.config.js
···
-
import adapter from '@sveltejs/adapter-auto';
+
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
+1 -7
tsconfig.json
···
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
-
"jsx": "react-jsx",
-
"paths": {
-
"$components": ["./src/components"],
-
"$components/*": ["./src/components/*"],
-
"$lib": ["./src/lib"],
-
"$lib/*": ["./src/lib/*"]
-
}
+
"jsx": "react-jsx"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files