replies timeline only, appview-less bluesky client

Compare changes

Choose any two refs to compare.

+440 -396
deno.lock
···
{
"version": "5",
"specifiers": {
-
"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:@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.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-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.8": {
-
"integrity": "sha512-Miu+S7RSgAYbmQWtHJKfSFUN5Kliqoo4YH0rILPmBtfmlZieORJgXNj9oO/Uive0/ulWkiRse07ATIcK8JxMnw==",
"dependencies": [
"@atcute/lexicons"
]
},
-
"@atcute/bluesky@3.2.9": {
-
"integrity": "sha512-69+mAnnH/uyMoT3/jHLBNILHa3+dm8utDKbm/2xqSPMLvRK47Wo5COlpchu8Xq+NGisHqukhHYT8NYdQFfSJhA==",
"dependencies": [
"@atcute/atproto",
"@atcute/lexicons"
]
},
-
"@atcute/client@4.0.5": {
-
"integrity": "sha512-R8Qen8goGmEkynYGg2m6XFlVmz0GTDvQ+9w+4QqOob+XMk8/WDpF4aImev7WKEde/rV2gjcqW7zM8E6W9NShDA==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons"
]
},
-
"@atcute/identity-resolver@1.1.4_@atcute+identity@1.1.1": {
-
"integrity": "sha512-/SVh8vf2cXFJenmBnGeYF2aY3WGQm3cJeew5NWTlkqoy3LvJ5wkvKq9PWu4Tv653VF40rPOp6LOdVr9Fa+q5rA==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons",
···
"@badrap/valita"
]
},
-
"@atcute/identity@1.1.1": {
-
"integrity": "sha512-zax42n693VEhnC+5tndvO2KLDTMkHOz8UExwmklvJv7R9VujfEwiSWhcv6Jgwb3ellaG8wjiQ1lMOIjLLvwh0Q==",
"dependencies": [
"@atcute/lexicons",
"@badrap/valita"
]
},
-
"@atcute/lexicons@1.2.2": {
-
"integrity": "sha512-bgEhJq5Z70/0TbK5sx+tAkrR8FsCODNiL2gUEvS5PuJfPxmFmRYNWaMGehxSPaXWpU2+Oa9ckceHiYbrItDTkA==",
"dependencies": [
"@standard-schema/spec",
"esm-env"
]
···
"@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",
···
"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.12": {
-
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"os": ["aix"],
"cpu": ["ppc64"]
},
-
"@esbuild/android-arm64@0.25.12": {
-
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"os": ["android"],
"cpu": ["arm64"]
},
-
"@esbuild/android-arm@0.25.12": {
-
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"os": ["android"],
"cpu": ["arm"]
},
-
"@esbuild/android-x64@0.25.12": {
-
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"os": ["android"],
"cpu": ["x64"]
},
-
"@esbuild/darwin-arm64@0.25.12": {
-
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"@esbuild/darwin-x64@0.25.12": {
-
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"@esbuild/freebsd-arm64@0.25.12": {
-
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
-
"@esbuild/freebsd-x64@0.25.12": {
-
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
-
"@esbuild/linux-arm64@0.25.12": {
-
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@esbuild/linux-arm@0.25.12": {
-
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@esbuild/linux-ia32@0.25.12": {
-
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"os": ["linux"],
"cpu": ["ia32"]
},
-
"@esbuild/linux-loong64@0.25.12": {
-
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"os": ["linux"],
"cpu": ["loong64"]
},
-
"@esbuild/linux-mips64el@0.25.12": {
-
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"os": ["linux"],
"cpu": ["mips64el"]
},
-
"@esbuild/linux-ppc64@0.25.12": {
-
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"os": ["linux"],
"cpu": ["ppc64"]
},
-
"@esbuild/linux-riscv64@0.25.12": {
-
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"os": ["linux"],
"cpu": ["riscv64"]
},
-
"@esbuild/linux-s390x@0.25.12": {
-
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"os": ["linux"],
"cpu": ["s390x"]
},
-
"@esbuild/linux-x64@0.25.12": {
-
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@esbuild/netbsd-arm64@0.25.12": {
-
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
-
"@esbuild/netbsd-x64@0.25.12": {
-
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"os": ["netbsd"],
"cpu": ["x64"]
},
-
"@esbuild/openbsd-arm64@0.25.12": {
-
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
-
"@esbuild/openbsd-x64@0.25.12": {
-
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"os": ["openbsd"],
"cpu": ["x64"]
},
-
"@esbuild/openharmony-arm64@0.25.12": {
-
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
-
"@esbuild/sunos-x64@0.25.12": {
-
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"os": ["sunos"],
"cpu": ["x64"]
},
-
"@esbuild/win32-arm64@0.25.12": {
-
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"os": ["win32"],
"cpu": ["arm64"]
},
-
"@esbuild/win32-ia32@0.25.12": {
-
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
-
"@esbuild/win32-x64@0.25.12": {
-
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"@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.2": {
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="
},
-
"@eslint/compat@1.4.1_eslint@9.39.0": {
-
"integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==",
"dependencies": [
-
"@eslint/core",
"eslint"
],
"optionalPeers": [
···
"@eslint/config-helpers@0.4.2": {
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dependencies": [
-
"@eslint/core"
]
},
"@eslint/core@0.17.0": {
···
"@types/json-schema"
]
},
-
"@eslint/eslintrc@3.3.1": {
-
"integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==",
"dependencies": [
"ajv",
"debug",
···
"strip-json-comments"
]
},
-
"@eslint/js@9.39.0": {
-
"integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw=="
},
"@eslint/object-schema@2.1.7": {
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="
···
"@eslint/plugin-kit@0.4.1": {
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dependencies": [
-
"@eslint/core",
"levn"
]
},
···
"@humanwhocodes/retry@0.4.3": {
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
},
-
"@iconify/svelte@5.1.0_svelte@5.43.2__acorn@8.15.0": {
"integrity": "sha512-I14nSqo0pNXO5OKsT61ZO3XIPF4yRHA2ErgPsaZ1sPJdKXn80o7o8jOe1xpWphbb9FihdX6by9zlKKBss61mFw==",
"dependencies": [
"@iconify/types",
···
"@jridgewell/sourcemap-codec"
]
},
-
"@nodelib/fs.scandir@2.1.5": {
-
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dependencies": [
-
"@nodelib/fs.stat",
-
"run-parallel"
]
},
-
"@nodelib/fs.stat@2.0.5": {
-
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
-
},
-
"@nodelib/fs.walk@1.2.8": {
-
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
-
"dependencies": [
-
"@nodelib/fs.scandir",
-
"fastq"
-
]
},
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
-
"@rollup/rollup-android-arm-eabi@4.52.5": {
-
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
"os": ["android"],
"cpu": ["arm"]
},
-
"@rollup/rollup-android-arm64@4.52.5": {
-
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
"os": ["android"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-darwin-arm64@4.52.5": {
-
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-darwin-x64@4.52.5": {
-
"integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"@rollup/rollup-freebsd-arm64@4.52.5": {
-
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
-
"@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.5": {
-
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@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.5": {
-
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-linux-arm64-musl@4.52.5": {
-
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-linux-loong64-gnu@4.52.5": {
-
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
"os": ["linux"],
"cpu": ["loong64"]
},
-
"@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.5": {
-
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
"os": ["linux"],
"cpu": ["riscv64"]
},
-
"@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.5": {
-
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
"os": ["linux"],
"cpu": ["s390x"]
},
-
"@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.5": {
-
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@rollup/rollup-openharmony-arm64@4.52.5": {
-
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-win32-arm64-msvc@4.52.5": {
-
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
"os": ["win32"],
"cpu": ["arm64"]
},
-
"@rollup/rollup-win32-ia32-msvc@4.52.5": {
-
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
"os": ["win32"],
"cpu": ["ia32"]
},
-
"@rollup/rollup-win32-x64-gnu@4.52.5": {
-
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"@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"
]
···
"typescript"
]
},
-
"@standard-schema/spec@1.0.0": {
-
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
},
-
"@sveltejs/acorn-typescript@1.0.6_acorn@8.15.0": {
-
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"dependencies": [
"acorn"
]
},
-
"@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.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.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.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.16": {
-
"integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"dependencies": [
"mini-svg-data-uri",
"tailwindcss"
]
},
-
"@tailwindcss/node@4.1.16": {
-
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"dependencies": [
"@jridgewell/remapping",
"enhanced-resolve",
···
"tailwindcss"
]
},
-
"@tailwindcss/oxide-android-arm64@4.1.16": {
-
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"os": ["android"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-darwin-arm64@4.1.16": {
-
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"os": ["darwin"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-darwin-x64@4.1.16": {
-
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"os": ["darwin"],
"cpu": ["x64"]
},
-
"@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.16": {
-
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"os": ["linux"],
"cpu": ["arm"]
},
-
"@tailwindcss/oxide-linux-arm64-gnu@4.1.16": {
-
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@tailwindcss/oxide-linux-arm64-musl@4.1.16": {
-
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"os": ["linux"],
"cpu": ["arm64"]
},
-
"@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.16": {
-
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"os": ["linux"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide-wasm32-wasi@4.1.16": {
-
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"cpu": ["wasm32"]
},
-
"@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.16": {
-
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"os": ["win32"],
"cpu": ["x64"]
},
-
"@tailwindcss/oxide@4.1.16": {
-
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"optionalDependencies": [
"@tailwindcss/oxide-android-arm64",
"@tailwindcss/oxide-darwin-arm64",
···
"@tailwindcss/oxide-win32-x64-msvc"
]
},
-
"@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",
···
"vite"
]
},
"@types/cookie@0.6.0": {
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
···
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
-
"@types/node@24.10.0": {
-
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dependencies": [
-
"undici-types"
]
},
-
"@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-eslint/utils",
"@typescript-eslint/visitor-keys",
"eslint",
-
"graphemer",
"ignore@7.0.5",
"natural-compare",
"ts-api-utils",
"typescript"
]
},
-
"@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.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.3": {
-
"integrity": "sha512-FCi7Y1zgrmxp3DfWfr+3m9ansUUFoy8dkEdeQSgA9gbm8DaHYvZCdkFRQrtKiedFf3Ha6VmoqoAaP68+i+22kg==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys"
]
},
-
"@typescript-eslint/tsconfig-utils@8.46.3_typescript@5.9.3": {
-
"integrity": "sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA==",
"dependencies": [
"typescript"
]
},
-
"@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.3": {
-
"integrity": "sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA=="
},
-
"@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-eslint/types",
"@typescript-eslint/visitor-keys",
"debug",
-
"fast-glob",
-
"is-glob",
"minimatch@9.0.5",
"semver",
"ts-api-utils",
"typescript"
]
},
-
"@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.3": {
-
"integrity": "sha512-uk574k8IU0rOF/AjniX8qbLSGURJVUCeM5e4MIMKBFFi8weeiLrG1fyQejyLXQpRZbU/1BuQasleV/RfHC3hHg==",
"dependencies": [
"@typescript-eslint/types",
"eslint-visitor-keys@4.2.1"
···
"aria-query@5.3.2": {
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
},
"axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
},
···
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": [
"balanced-match"
-
]
-
},
-
"braces@3.0.3": {
-
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
-
"dependencies": [
-
"fill-range"
]
},
"callsites@3.1.0": {
···
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
-
"devalue@5.4.2": {
-
"integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw=="
},
-
"enhanced-resolve@5.18.3": {
-
"integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==",
"dependencies": [
"graceful-fs",
"tapable"
]
},
-
"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.39.0": {
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dependencies": [
"eslint"
],
"bin": true
},
-
"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-visitor-keys@4.2.1": {
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="
},
-
"eslint@9.39.0": {
-
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dependencies": [
"@eslint-community/eslint-utils",
"@eslint-community/regexpp",
"@eslint/config-array",
"@eslint/config-helpers",
-
"@eslint/core",
"@eslint/eslintrc",
"@eslint/js",
"@eslint/plugin-kit",
···
"fast-deep-equal",
"file-entry-cache",
"find-up",
-
"glob-parent@6.0.2",
"ignore@5.3.2",
"imurmurhash",
"is-glob",
···
"eslint-visitor-keys@4.2.1"
]
},
-
"esquery@1.6.0": {
-
"integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
"dependencies": [
"estraverse"
]
},
-
"esrap@2.1.2": {
-
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
···
"esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
-
"fast-glob@3.3.3": {
-
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
-
"dependencies": [
-
"@nodelib/fs.stat",
-
"@nodelib/fs.walk",
-
"glob-parent@5.1.2",
-
"merge2",
-
"micromatch"
-
]
-
},
"fast-json-stable-stringify@2.1.0": {
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"fast-levenshtein@2.0.6": {
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
-
"fastq@1.19.1": {
-
"integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
-
"dependencies": [
-
"reusify"
-
]
-
},
"fdir@6.5.0_picomatch@4.0.3": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
-
"picomatch@4.0.3"
],
"optionalPeers": [
-
"picomatch@4.0.3"
]
},
"file-entry-cache@8.0.0": {
···
"flat-cache"
]
},
-
"fill-range@7.1.1": {
-
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
-
"dependencies": [
-
"to-regex-range"
-
]
-
},
"find-up@5.0.0": {
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dependencies": [
···
"os": ["darwin"],
"scripts": true
},
-
"glob-parent@5.1.2": {
-
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
-
"dependencies": [
-
"is-glob"
-
]
-
},
"glob-parent@6.0.2": {
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dependencies": [
···
"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=="
-
},
-
"graphemer@1.4.0": {
-
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="
},
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
···
"is-extglob"
]
},
-
"is-number@7.0.0": {
-
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
-
},
"is-reference@3.0.3": {
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dependencies": [
···
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"bin": true
},
-
"js-yaml@4.1.0": {
-
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dependencies": [
"argparse"
],
···
"lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
-
"lru-cache@11.2.2": {
-
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg=="
},
"magic-string@0.30.21": {
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": [
"@jridgewell/sourcemap-codec"
-
]
-
},
-
"merge2@1.4.1": {
-
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
-
},
-
"micromatch@4.0.8": {
-
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
-
"dependencies": [
-
"braces",
-
"picomatch@2.3.1"
]
},
"mini-svg-data-uri@1.4.4": {
···
"brace-expansion@2.0.2"
]
},
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
···
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
"optionator@0.9.4": {
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dependencies": [
···
"p-limit@3.1.0": {
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dependencies": [
-
"yocto-queue"
]
},
"p-locate@5.0.0": {
···
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dependencies": [
"callsites"
]
},
"path-exists@4.0.0": {
···
"path-key@3.1.1": {
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
-
},
-
"picomatch@2.3.1": {
-
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"picomatch@4.0.3": {
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
···
"postcss"
]
},
-
"postcss-selector-parser@7.1.0": {
-
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"dependencies": [
"cssesc",
"util-deprecate"
···
"prelude-ls@1.2.1": {
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
},
-
"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.43.2___acorn@8.15.0_svelte@5.43.2__acorn@8.15.0": {
-
"integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==",
"dependencies": [
"prettier",
"prettier-plugin-svelte"
···
"prettier-plugin-svelte"
]
},
-
"prettier@3.6.2": {
-
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"bin": true
},
"punycode@2.3.1": {
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
},
-
"queue-microtask@1.2.3": {
-
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
-
},
"readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
},
"resolve-from@4.0.0": {
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
},
-
"reusify@1.1.0": {
-
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
-
},
-
"rollup@4.52.5": {
-
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"dependencies": [
"@types/estree"
],
···
],
"bin": true
},
-
"run-parallel@1.2.0": {
-
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
-
"dependencies": [
-
"queue-microtask"
-
]
-
},
"sade@1.8.1": {
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dependencies": [
"mri"
]
},
"semver@7.7.3": {
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
···
"has-flag"
]
},
-
"svelte-awesome-color-picker@4.1.0_svelte@5.43.2__acorn@8.15.0": {
"integrity": "sha512-afiSB3eTBlqu96f4+rjBvqG3eCaLwuneNYHe587Wr4Ien6yQWeztGZunPT0FmiI7wFFBVNUlJQLYutII8LfQUg==",
"dependencies": [
"colord",
···
"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",
"chokidar",
···
"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-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",
···
"aria-query",
"axobject-query",
"clsx",
"esm-env",
"esrap",
"is-reference",
···
"zimmerframe"
]
},
-
"tailwindcss@4.1.16": {
-
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="
},
"tapable@2.3.0": {
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="
···
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dependencies": [
"fdir",
-
"picomatch@4.0.3"
-
]
-
},
-
"to-regex-range@5.0.1": {
-
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
-
"dependencies": [
-
"is-number"
]
},
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
-
"ts-api-utils@2.1.0_typescript@5.9.3": {
-
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
"dependencies": [
"typescript"
]
···
"prelude-ls"
]
},
-
"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.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.12_@types+node@24.10.0_picomatch@4.0.3": {
-
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dependencies": [
-
"@types/node",
"esbuild",
"fdir",
-
"picomatch@4.0.3",
"postcss",
"rollup",
"tinyglobby"
···
"fsevents"
],
"optionalPeers": [
-
"@types/node"
],
"bin": true
},
-
"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"
···
"yocto-queue@0.1.0": {
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
"zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
}
···
"workspace": {
"packageJson": {
"dependencies": [
-
"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:@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.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.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-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"
]
}
}
···
{
"version": "5",
"specifiers": {
+
"npm:@atcute/atproto@^3.1.10": "3.1.10",
+
"npm:@atcute/bluesky-richtext-builder@^2.0.4": "2.0.4",
+
"npm:@atcute/bluesky-richtext-segmenter@^2.0.4": "2.0.4",
+
"npm:@atcute/bluesky@^3.2.14": "3.2.14",
+
"npm:@atcute/client@^4.2.0": "4.2.0",
+
"npm:@atcute/identity-resolver@^1.2.1": "1.2.1_@atcute+identity@1.1.3",
+
"npm:@atcute/identity@^1.1.3": "1.1.3",
+
"npm:@atcute/jetstream@^1.1.2": "1.1.2",
+
"npm:@atcute/lexicons@^1.2.6": "1.2.6",
+
"npm:@atcute/oauth-browser-client@^2.0.3": "2.0.3_@atcute+identity@1.1.3",
+
"npm:@atcute/tid@^1.1.1": "1.1.1",
+
"npm:@eslint/compat@2": "2.0.0_eslint@9.39.2",
+
"npm:@eslint/js@^9.39.2": "9.39.2",
"npm:@floating-ui/dom@^1.7.4": "1.7.4",
+
"npm:@iconify/svelte@^5.1.0": "5.1.0_svelte@5.46.1__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.49.2__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.46.1____acorn@8.15.0___vite@7.3.0____@types+node@25.0.3____picomatch@4.0.3___@types+node@25.0.3__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__acorn@8.15.0__@types+node@25.0.3_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__@types+node@25.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
+
"npm:@sveltejs/kit@^2.49.2": "2.49.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__@types+node@25.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_acorn@8.15.0_@types+node@25.0.3",
+
"npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
+
"npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.1.18",
+
"npm:@tailwindcss/vite@^4.1.18": "4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3",
+
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18": "3.0.18_svelte@5.46.1__acorn@8.15.0",
+
"npm:@types/node@^25.0.3": "25.0.3",
"npm:@wora/cache-persist@^2.2.1": "2.2.1",
+
"npm:async-cache-dedupe@^3.4.0": "3.4.0",
+
"npm:eslint-config-prettier@^10.1.8": "10.1.8_eslint@9.39.2",
+
"npm:eslint-plugin-svelte@^3.13.1": "3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6",
+
"npm:eslint@^9.39.2": "9.39.2",
+
"npm:globals@17": "17.0.0",
"npm:hash-wasm@^4.12.0": "4.12.0",
+
"npm:lru-cache@^11.2.4": "11.2.4",
+
"npm:photoswipe@^5.4.4": "5.4.4",
+
"npm:prettier-plugin-svelte@^3.4.1": "3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0",
+
"npm:prettier-plugin-tailwindcss@~0.7.2": "0.7.2_prettier@3.7.4_prettier-plugin-svelte@3.4.1__prettier@3.7.4__svelte@5.46.1___acorn@8.15.0_svelte@5.46.1__acorn@8.15.0",
+
"npm:prettier@^3.7.4": "3.7.4",
+
"npm:svelte-awesome-color-picker@^4.1.0": "4.1.0_svelte@5.46.1__acorn@8.15.0",
+
"npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__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.46.1__acorn@8.15.0",
"npm:svelte-portal@^2.2.1": "2.2.1",
+
"npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0",
+
"npm:tailwindcss@^4.1.18": "4.1.18",
+
"npm:typescript-eslint@^8.51.0": "8.51.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.51.0__eslint@9.39.2__typescript@5.9.3",
"npm:typescript@^5.9.3": "5.9.3",
+
"npm:vite@^7.3.0": "7.3.0_@types+node@25.0.3_picomatch@4.0.3"
},
"npm": {
+
"@atcute/atproto@3.1.10": {
+
"integrity": "sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==",
"dependencies": [
"@atcute/lexicons"
]
},
+
"@atcute/bluesky-richtext-builder@2.0.4": {
+
"integrity": "sha512-ydA9VWBPsBE/gbu1vYbmh7AZ8FLfxp+LE4eH5GgOTCOxwhs7Mgy1oHrHY+Er6gu6PfdoUoGso0uI3Wl3ZF/Mxg==",
+
"dependencies": [
+
"@atcute/bluesky",
+
"@atcute/lexicons"
+
]
+
},
+
"@atcute/bluesky-richtext-segmenter@2.0.4": {
+
"integrity": "sha512-6m5QEAv4lU3qTy5MeJXJRRG33acipYJnMW1T7W/KrMyThGhQ7jSTTh8Z48quElgivgX7MDj6o/ow1oLUsjsCKw==",
+
"dependencies": [
+
"@atcute/bluesky",
+
"@atcute/lexicons"
+
]
+
},
+
"@atcute/bluesky@3.2.14": {
+
"integrity": "sha512-XlVuF55AYIyplmKvlGLlj+cUvk9ggxNRPczkTPIY991xJ4qDxDHpBJ39ekAV4dWcuBoRo2o9JynzpafPu2ljDA==",
"dependencies": [
"@atcute/atproto",
"@atcute/lexicons"
]
},
+
"@atcute/client@4.2.0": {
+
"integrity": "sha512-vYixpXevM+dkzN4HGTfmxCJBOXNSRAMki1bfi1atFdmZGo9n1zStyClHqn0SRs8I5nOQoVG1XBkYAGSFJWqy2Q==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons"
]
},
+
"@atcute/identity-resolver@1.2.1_@atcute+identity@1.1.3": {
+
"integrity": "sha512-LqWFFf8D8bqW8l0zUV9oZxcXYZ8+uQTZfjURoxH1TLmtmZFSXredtQHsY70k/iSMNDPxWHJXebdlKxJm5ioNIg==",
"dependencies": [
"@atcute/identity",
"@atcute/lexicons",
···
"@badrap/valita"
]
},
+
"@atcute/identity@1.1.3": {
+
"integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==",
"dependencies": [
"@atcute/lexicons",
"@badrap/valita"
]
},
+
"@atcute/jetstream@1.1.2": {
+
"integrity": "sha512-u6p/h2xppp7LE6W/9xErAJ6frfN60s8adZuCKtfAaaBBiiYbb1CfpzN8Uc+2qtJZNorqGvuuDb5572Jmh7yHBQ==",
"dependencies": [
+
"@atcute/lexicons",
+
"@badrap/valita",
+
"@mary-ext/event-iterator",
+
"@mary-ext/simple-event-emitter",
+
"partysocket",
+
"type-fest",
+
"yocto-queue@1.2.2"
+
]
+
},
+
"@atcute/lexicons@1.2.6": {
+
"integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==",
+
"dependencies": [
+
"@atcute/uint8array",
+
"@atcute/util-text",
"@standard-schema/spec",
"esm-env"
]
···
"@atcute/uint8array"
]
},
+
"@atcute/oauth-browser-client@2.0.3_@atcute+identity@1.1.3": {
+
"integrity": "sha512-rzUjwhjE4LRRKdQnCFQag/zXRZMEAB1hhBoLfnoQuHwWbmDUCL7fzwC3jRhDPp3om8XaYNDj8a/iqRip0wRqoQ==",
"dependencies": [
"@atcute/client",
"@atcute/identity-resolver",
"@atcute/lexicons",
"@atcute/multibase",
···
"nanoid@5.1.6"
]
},
+
"@atcute/tid@1.1.1": {
+
"integrity": "sha512-djJ8UGhLkTU5V51yCnBEruMg35qETjWzWy5sJG/2gEOl2Gd7rQWHSaf+yrO6vMS5EFA38U2xOWE3EDUPzvc2ZQ==",
+
"dependencies": [
+
"@atcute/time-ms"
+
]
},
+
"@atcute/time-ms@1.0.0": {
+
"integrity": "sha512-iWEOlMBcO3ktB+zQPC2kXka9H/798we+IWq2sjhb+hQJNNfcJrwejzvNi/68Q3jKo/hdfwZjRU9iF8U6D32/2Q==",
+
"dependencies": [
+
"@types/node@22.19.3",
+
"node-gyp-build"
+
],
+
"scripts": true
},
+
"@atcute/uint8array@1.0.6": {
+
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
+
},
+
"@atcute/util-fetch@1.0.4": {
+
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
"dependencies": [
"@badrap/valita"
]
},
+
"@atcute/util-text@0.0.1": {
+
"integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==",
+
"dependencies": [
+
"unicode-segmenter"
+
]
+
},
"@badrap/valita@0.4.6": {
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
},
+
"@esbuild/aix-ppc64@0.27.2": {
+
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
"os": ["aix"],
"cpu": ["ppc64"]
},
+
"@esbuild/android-arm64@0.27.2": {
+
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
"os": ["android"],
"cpu": ["arm64"]
},
+
"@esbuild/android-arm@0.27.2": {
+
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
"os": ["android"],
"cpu": ["arm"]
},
+
"@esbuild/android-x64@0.27.2": {
+
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
"os": ["android"],
"cpu": ["x64"]
},
+
"@esbuild/darwin-arm64@0.27.2": {
+
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
+
"@esbuild/darwin-x64@0.27.2": {
+
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
"os": ["darwin"],
"cpu": ["x64"]
},
+
"@esbuild/freebsd-arm64@0.27.2": {
+
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
+
"@esbuild/freebsd-x64@0.27.2": {
+
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
"os": ["freebsd"],
"cpu": ["x64"]
},
+
"@esbuild/linux-arm64@0.27.2": {
+
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
"os": ["linux"],
"cpu": ["arm64"]
},
+
"@esbuild/linux-arm@0.27.2": {
+
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
"os": ["linux"],
"cpu": ["arm"]
},
+
"@esbuild/linux-ia32@0.27.2": {
+
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
"os": ["linux"],
"cpu": ["ia32"]
},
+
"@esbuild/linux-loong64@0.27.2": {
+
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
"os": ["linux"],
"cpu": ["loong64"]
},
+
"@esbuild/linux-mips64el@0.27.2": {
+
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
"os": ["linux"],
"cpu": ["mips64el"]
},
+
"@esbuild/linux-ppc64@0.27.2": {
+
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
"os": ["linux"],
"cpu": ["ppc64"]
},
+
"@esbuild/linux-riscv64@0.27.2": {
+
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
"os": ["linux"],
"cpu": ["riscv64"]
},
+
"@esbuild/linux-s390x@0.27.2": {
+
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
"os": ["linux"],
"cpu": ["s390x"]
},
+
"@esbuild/linux-x64@0.27.2": {
+
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
"os": ["linux"],
"cpu": ["x64"]
},
+
"@esbuild/netbsd-arm64@0.27.2": {
+
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
"os": ["netbsd"],
"cpu": ["arm64"]
},
+
"@esbuild/netbsd-x64@0.27.2": {
+
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
"os": ["netbsd"],
"cpu": ["x64"]
},
+
"@esbuild/openbsd-arm64@0.27.2": {
+
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
"os": ["openbsd"],
"cpu": ["arm64"]
},
+
"@esbuild/openbsd-x64@0.27.2": {
+
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
"os": ["openbsd"],
"cpu": ["x64"]
},
+
"@esbuild/openharmony-arm64@0.27.2": {
+
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
+
"@esbuild/sunos-x64@0.27.2": {
+
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
"os": ["sunos"],
"cpu": ["x64"]
},
+
"@esbuild/win32-arm64@0.27.2": {
+
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
"os": ["win32"],
"cpu": ["arm64"]
},
+
"@esbuild/win32-ia32@0.27.2": {
+
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
+
"@esbuild/win32-x64@0.27.2": {
+
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
"os": ["win32"],
"cpu": ["x64"]
},
+
"@eslint-community/eslint-utils@4.9.1_eslint@9.39.2": {
+
"integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
"dependencies": [
"eslint",
"eslint-visitor-keys@3.4.3"
···
"@eslint-community/regexpp@4.12.2": {
"integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="
},
+
"@eslint/compat@2.0.0_eslint@9.39.2": {
+
"integrity": "sha512-T9AfE1G1uv4wwq94ozgTGio5EUQBqAVe1X9qsQtSNVEYW6j3hvtZVm8Smr4qL1qDPFg+lOB2cL5RxTRMzq4CTA==",
"dependencies": [
+
"@eslint/core@1.0.0",
"eslint"
],
"optionalPeers": [
···
"@eslint/config-helpers@0.4.2": {
"integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
"dependencies": [
+
"@eslint/core@0.17.0"
]
},
"@eslint/core@0.17.0": {
···
"@types/json-schema"
]
},
+
"@eslint/core@1.0.0": {
+
"integrity": "sha512-PRfWP+8FOldvbApr6xL7mNCw4cJcSTq4GA7tYbgq15mRb0kWKO/wEB2jr+uwjFH3sZvEZneZyCUGTxsv4Sahyw==",
+
"dependencies": [
+
"@types/json-schema"
+
]
+
},
+
"@eslint/eslintrc@3.3.3": {
+
"integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==",
"dependencies": [
"ajv",
"debug",
···
"strip-json-comments"
]
},
+
"@eslint/js@9.39.2": {
+
"integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="
},
"@eslint/object-schema@2.1.7": {
"integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="
···
"@eslint/plugin-kit@0.4.1": {
"integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
"dependencies": [
+
"@eslint/core@0.17.0",
"levn"
]
},
···
"@humanwhocodes/retry@0.4.3": {
"integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="
},
+
"@iconify/svelte@5.1.0_svelte@5.46.1__acorn@8.15.0": {
"integrity": "sha512-I14nSqo0pNXO5OKsT61ZO3XIPF4yRHA2ErgPsaZ1sPJdKXn80o7o8jOe1xpWphbb9FihdX6by9zlKKBss61mFw==",
"dependencies": [
"@iconify/types",
···
"@jridgewell/sourcemap-codec"
]
},
+
"@mary-ext/event-iterator@1.0.0": {
+
"integrity": "sha512-l6gCPsWJ8aRCe/s7/oCmero70kDHgIK5m4uJvYgwEYTqVxoBOIXbKr5tnkLqUHEg6mNduB4IWvms3h70Hp9ADQ==",
"dependencies": [
+
"yocto-queue@1.2.2"
]
},
+
"@mary-ext/simple-event-emitter@1.0.0": {
+
"integrity": "sha512-meA/zJZKIN1RVBNEYIbjufkUrW7/tRjHH60FjolpG1ixJKo76TB208qefQLNdOVDA7uIG0CGEDuhmMirtHKLAg=="
},
"@polka/url@1.0.0-next.29": {
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="
},
+
"@rollup/rollup-android-arm-eabi@4.54.0": {
+
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"os": ["android"],
"cpu": ["arm"]
},
+
"@rollup/rollup-android-arm64@4.54.0": {
+
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
"os": ["android"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-darwin-arm64@4.54.0": {
+
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
"os": ["darwin"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-darwin-x64@4.54.0": {
+
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
"os": ["darwin"],
"cpu": ["x64"]
},
+
"@rollup/rollup-freebsd-arm64@4.54.0": {
+
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
"os": ["freebsd"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-freebsd-x64@4.54.0": {
+
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
"os": ["freebsd"],
"cpu": ["x64"]
},
+
"@rollup/rollup-linux-arm-gnueabihf@4.54.0": {
+
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
"os": ["linux"],
"cpu": ["arm"]
},
+
"@rollup/rollup-linux-arm-musleabihf@4.54.0": {
+
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
"os": ["linux"],
"cpu": ["arm"]
},
+
"@rollup/rollup-linux-arm64-gnu@4.54.0": {
+
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
"os": ["linux"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-linux-arm64-musl@4.54.0": {
+
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
"os": ["linux"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-linux-loong64-gnu@4.54.0": {
+
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
"os": ["linux"],
"cpu": ["loong64"]
},
+
"@rollup/rollup-linux-ppc64-gnu@4.54.0": {
+
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
"os": ["linux"],
"cpu": ["ppc64"]
},
+
"@rollup/rollup-linux-riscv64-gnu@4.54.0": {
+
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
"os": ["linux"],
"cpu": ["riscv64"]
},
+
"@rollup/rollup-linux-riscv64-musl@4.54.0": {
+
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
"os": ["linux"],
"cpu": ["riscv64"]
},
+
"@rollup/rollup-linux-s390x-gnu@4.54.0": {
+
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
"os": ["linux"],
"cpu": ["s390x"]
},
+
"@rollup/rollup-linux-x64-gnu@4.54.0": {
+
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
"os": ["linux"],
"cpu": ["x64"]
},
+
"@rollup/rollup-linux-x64-musl@4.54.0": {
+
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
"os": ["linux"],
"cpu": ["x64"]
},
+
"@rollup/rollup-openharmony-arm64@4.54.0": {
+
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-win32-arm64-msvc@4.54.0": {
+
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
"os": ["win32"],
"cpu": ["arm64"]
},
+
"@rollup/rollup-win32-ia32-msvc@4.54.0": {
+
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
"os": ["win32"],
"cpu": ["ia32"]
},
+
"@rollup/rollup-win32-x64-gnu@4.54.0": {
+
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
"os": ["win32"],
"cpu": ["x64"]
},
+
"@rollup/rollup-win32-x64-msvc@4.54.0": {
+
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
"os": ["win32"],
"cpu": ["x64"]
},
+
"@soffinal/stream@0.2.4_typescript@5.9.3": {
+
"integrity": "sha512-2ViwKVcOcbths5wCrizGbcoE5aaURHZkQlIzWv6K6cA6OY2Khjx2a7EOHoWA1iLho6+bSDo81W0ev85zZZxYHg==",
"dependencies": [
"typescript"
]
···
"typescript"
]
},
+
"@standard-schema/spec@1.1.0": {
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
+
"@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
+
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
"dependencies": [
"acorn"
]
},
+
"@sveltejs/adapter-static@3.0.10_@sveltejs+kit@2.49.2__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.46.1____acorn@8.15.0___vite@7.3.0____@types+node@25.0.3____picomatch@4.0.3___@types+node@25.0.3__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__acorn@8.15.0__@types+node@25.0.3_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__@types+node@25.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3": {
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dependencies": [
"@sveltejs/kit"
]
},
+
"@sveltejs/kit@2.49.2_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__@types+node@25.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_acorn@8.15.0_@types+node@25.0.3": {
+
"integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
"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.46.1___acorn@8.15.0__vite@7.3.0___@types+node@25.0.3___picomatch@4.0.3__@types+node@25.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3": {
"integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==",
"dependencies": [
"@sveltejs/vite-plugin-svelte",
···
"vite"
]
},
+
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3": {
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"dependencies": [
"@sveltejs/vite-plugin-svelte-inspector",
···
"vitefu"
]
},
+
"@tailwindcss/forms@0.5.11_tailwindcss@4.1.18": {
+
"integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==",
"dependencies": [
"mini-svg-data-uri",
"tailwindcss"
]
},
+
"@tailwindcss/node@4.1.18": {
+
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
"dependencies": [
"@jridgewell/remapping",
"enhanced-resolve",
···
"tailwindcss"
]
},
+
"@tailwindcss/oxide-android-arm64@4.1.18": {
+
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
"os": ["android"],
"cpu": ["arm64"]
},
+
"@tailwindcss/oxide-darwin-arm64@4.1.18": {
+
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
"os": ["darwin"],
"cpu": ["arm64"]
},
+
"@tailwindcss/oxide-darwin-x64@4.1.18": {
+
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
"os": ["darwin"],
"cpu": ["x64"]
},
+
"@tailwindcss/oxide-freebsd-x64@4.1.18": {
+
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
"os": ["freebsd"],
"cpu": ["x64"]
},
+
"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18": {
+
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
"os": ["linux"],
"cpu": ["arm"]
},
+
"@tailwindcss/oxide-linux-arm64-gnu@4.1.18": {
+
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
"os": ["linux"],
"cpu": ["arm64"]
},
+
"@tailwindcss/oxide-linux-arm64-musl@4.1.18": {
+
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
"os": ["linux"],
"cpu": ["arm64"]
},
+
"@tailwindcss/oxide-linux-x64-gnu@4.1.18": {
+
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
"os": ["linux"],
"cpu": ["x64"]
},
+
"@tailwindcss/oxide-linux-x64-musl@4.1.18": {
+
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
"os": ["linux"],
"cpu": ["x64"]
},
+
"@tailwindcss/oxide-wasm32-wasi@4.1.18": {
+
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
"cpu": ["wasm32"]
},
+
"@tailwindcss/oxide-win32-arm64-msvc@4.1.18": {
+
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
"os": ["win32"],
"cpu": ["arm64"]
},
+
"@tailwindcss/oxide-win32-x64-msvc@4.1.18": {
+
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
"os": ["win32"],
"cpu": ["x64"]
},
+
"@tailwindcss/oxide@4.1.18": {
+
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
"optionalDependencies": [
"@tailwindcss/oxide-android-arm64",
"@tailwindcss/oxide-darwin-arm64",
···
"@tailwindcss/oxide-win32-x64-msvc"
]
},
+
"@tailwindcss/vite@4.1.18_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3": {
+
"integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==",
"dependencies": [
"@tailwindcss/node",
"@tailwindcss/oxide",
···
"vite"
]
},
+
"@tutorlatin/svelte-tiny-virtual-list@3.0.18_svelte@5.46.1__acorn@8.15.0": {
+
"integrity": "sha512-In7ASkVkLhg0ClWEVA50J/hWrGovwkw7dfHYlUyQJz4rPvh1TGpfo0lN1mXWERj2bkWY8AYITSbBZnVz34tcfA==",
+
"dependencies": [
+
"svelte"
+
]
+
},
"@types/cookie@0.6.0": {
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
},
···
"@types/json-schema@7.0.15": {
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
+
"@types/node@22.19.3": {
+
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dependencies": [
+
"undici-types@6.21.0"
]
},
+
"@types/node@25.0.3": {
+
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
+
"dependencies": [
+
"undici-types@7.16.0"
+
]
+
},
+
"@typescript-eslint/eslint-plugin@8.51.0_@typescript-eslint+parser@8.51.0__eslint@9.39.2__typescript@5.9.3_eslint@9.39.2_typescript@5.9.3": {
+
"integrity": "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==",
"dependencies": [
"@eslint-community/regexpp",
"@typescript-eslint/parser",
···
"@typescript-eslint/utils",
"@typescript-eslint/visitor-keys",
"eslint",
"ignore@7.0.5",
"natural-compare",
"ts-api-utils",
"typescript"
]
},
+
"@typescript-eslint/parser@8.51.0_eslint@9.39.2_typescript@5.9.3": {
+
"integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==",
"dependencies": [
"@typescript-eslint/scope-manager",
"@typescript-eslint/types",
···
"typescript"
]
},
+
"@typescript-eslint/project-service@8.51.0_typescript@5.9.3": {
+
"integrity": "sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==",
"dependencies": [
"@typescript-eslint/tsconfig-utils",
"@typescript-eslint/types",
···
"typescript"
]
},
+
"@typescript-eslint/scope-manager@8.51.0": {
+
"integrity": "sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys"
]
},
+
"@typescript-eslint/tsconfig-utils@8.51.0_typescript@5.9.3": {
+
"integrity": "sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==",
"dependencies": [
"typescript"
]
},
+
"@typescript-eslint/type-utils@8.51.0_eslint@9.39.2_typescript@5.9.3": {
+
"integrity": "sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==",
"dependencies": [
"@typescript-eslint/types",
"@typescript-eslint/typescript-estree",
···
"typescript"
]
},
+
"@typescript-eslint/types@8.51.0": {
+
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag=="
},
+
"@typescript-eslint/typescript-estree@8.51.0_typescript@5.9.3": {
+
"integrity": "sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==",
"dependencies": [
"@typescript-eslint/project-service",
"@typescript-eslint/tsconfig-utils",
"@typescript-eslint/types",
"@typescript-eslint/visitor-keys",
"debug",
"minimatch@9.0.5",
"semver",
+
"tinyglobby",
"ts-api-utils",
"typescript"
]
},
+
"@typescript-eslint/utils@8.51.0_eslint@9.39.2_typescript@5.9.3": {
+
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
"dependencies": [
"@eslint-community/eslint-utils",
"@typescript-eslint/scope-manager",
···
"typescript"
]
},
+
"@typescript-eslint/visitor-keys@8.51.0": {
+
"integrity": "sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==",
"dependencies": [
"@typescript-eslint/types",
"eslint-visitor-keys@4.2.1"
···
"aria-query@5.3.2": {
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
},
+
"async-cache-dedupe@3.4.0": {
+
"integrity": "sha512-RkQr21CpltqMpbYpRaEAmF1BdUO5jnnS/scZkectmLiuWQ81w8u4lYraipbQf8zQ0yYvb3U0N1ozNAYmI4jQ3g==",
+
"dependencies": [
+
"mnemonist",
+
"safe-stable-stringify"
+
]
+
},
"axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
},
···
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": [
"balanced-match"
]
},
"callsites@3.1.0": {
···
"detect-libc@2.1.2": {
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="
},
+
"devalue@5.6.1": {
+
"integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="
},
+
"enhanced-resolve@5.18.4": {
+
"integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==",
"dependencies": [
"graceful-fs",
"tapable"
]
},
+
"esbuild@0.27.2": {
+
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"optionalDependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
···
"escape-string-regexp@4.0.0": {
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
},
+
"eslint-config-prettier@10.1.8_eslint@9.39.2": {
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dependencies": [
"eslint"
],
"bin": true
},
+
"eslint-plugin-svelte@3.13.1_eslint@9.39.2_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6": {
+
"integrity": "sha512-Ng+kV/qGS8P/isbNYVE3sJORtubB+yLEcYICMkUWNaDTb0SwZni/JhAYXh/Dz/q2eThUwWY0VMPZ//KYD1n3eQ==",
"dependencies": [
"@eslint-community/eslint-utils",
"@jridgewell/sourcemap-codec",
···
"eslint-visitor-keys@4.2.1": {
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="
},
+
"eslint@9.39.2": {
+
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dependencies": [
"@eslint-community/eslint-utils",
"@eslint-community/regexpp",
"@eslint/config-array",
"@eslint/config-helpers",
+
"@eslint/core@0.17.0",
"@eslint/eslintrc",
"@eslint/js",
"@eslint/plugin-kit",
···
"fast-deep-equal",
"file-entry-cache",
"find-up",
+
"glob-parent",
"ignore@5.3.2",
"imurmurhash",
"is-glob",
···
"eslint-visitor-keys@4.2.1"
]
},
+
"esquery@1.7.0": {
+
"integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
"dependencies": [
"estraverse"
]
},
+
"esrap@2.2.1": {
+
"integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
···
"esutils@2.0.3": {
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
+
"event-target-polyfill@0.0.4": {
+
"integrity": "sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ=="
+
},
"fast-deep-equal@3.1.3": {
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-json-stable-stringify@2.1.0": {
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"fast-levenshtein@2.0.6": {
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="
},
"fdir@6.5.0_picomatch@4.0.3": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
+
"picomatch"
],
"optionalPeers": [
+
"picomatch"
]
},
"file-entry-cache@8.0.0": {
···
"flat-cache"
]
},
"find-up@5.0.0": {
"integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
"dependencies": [
···
"os": ["darwin"],
"scripts": true
},
"glob-parent@6.0.2": {
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dependencies": [
···
"globals@16.5.0": {
"integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="
},
+
"globals@17.0.0": {
+
"integrity": "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="
+
},
"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=="
···
"is-extglob"
]
},
"is-reference@3.0.3": {
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dependencies": [
···
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"bin": true
},
+
"js-yaml@4.1.1": {
+
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dependencies": [
"argparse"
],
···
"lodash.merge@4.6.2": {
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
+
"lru-cache@11.2.4": {
+
"integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="
},
"magic-string@0.30.21": {
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dependencies": [
"@jridgewell/sourcemap-codec"
]
},
"mini-svg-data-uri@1.4.4": {
···
"brace-expansion@2.0.2"
]
},
+
"mnemonist@0.40.3": {
+
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
+
"dependencies": [
+
"obliterator"
+
]
+
},
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
···
"natural-compare@1.4.0": {
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="
},
+
"node-gyp-build@4.8.4": {
+
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+
"bin": true
+
},
+
"obliterator@2.0.5": {
+
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="
+
},
"optionator@0.9.4": {
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"dependencies": [
···
"p-limit@3.1.0": {
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dependencies": [
+
"yocto-queue@0.1.0"
]
},
"p-locate@5.0.0": {
···
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"dependencies": [
"callsites"
+
]
+
},
+
"partysocket@1.1.10": {
+
"integrity": "sha512-ACfn0P6lQuj8/AqB4L5ZDFcIEbpnIteNNObrlxqV1Ge80GTGhjuJ2sNKwNQlFzhGi4kI7fP/C1Eqh8TR78HjDQ==",
+
"dependencies": [
+
"event-target-polyfill"
]
},
"path-exists@4.0.0": {
···
"path-key@3.1.1": {
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
},
+
"photoswipe@5.4.4": {
+
"integrity": "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA=="
+
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@4.0.3": {
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
···
"postcss"
]
},
+
"postcss-selector-parser@7.1.1": {
+
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dependencies": [
"cssesc",
"util-deprecate"
···
"prelude-ls@1.2.1": {
"integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="
},
+
"prettier-plugin-svelte@3.4.1_prettier@3.7.4_svelte@5.46.1__acorn@8.15.0": {
+
"integrity": "sha512-xL49LCloMoZRvSwa6IEdN2GV6cq2IqpYGstYtMT+5wmml1/dClEoI0MZR78MiVPpu6BdQFfN0/y73yO6+br5Pg==",
"dependencies": [
"prettier",
"svelte"
]
},
+
"prettier-plugin-tailwindcss@0.7.2_prettier@3.7.4_prettier-plugin-svelte@3.4.1__prettier@3.7.4__svelte@5.46.1___acorn@8.15.0_svelte@5.46.1__acorn@8.15.0": {
+
"integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==",
"dependencies": [
"prettier",
"prettier-plugin-svelte"
···
"prettier-plugin-svelte"
]
},
+
"prettier@3.7.4": {
+
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"bin": true
},
"punycode@2.3.1": {
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
},
"readdirp@4.1.2": {
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
},
"resolve-from@4.0.0": {
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
},
+
"rollup@4.54.0": {
+
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dependencies": [
"@types/estree"
],
···
],
"bin": true
},
"sade@1.8.1": {
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dependencies": [
"mri"
]
+
},
+
"safe-stable-stringify@2.5.0": {
+
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="
},
"semver@7.7.3": {
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
···
"has-flag"
]
},
+
"svelte-awesome-color-picker@4.1.0_svelte@5.46.1__acorn@8.15.0": {
"integrity": "sha512-afiSB3eTBlqu96f4+rjBvqG3eCaLwuneNYHe587Wr4Ien6yQWeztGZunPT0FmiI7wFFBVNUlJQLYutII8LfQUg==",
"dependencies": [
"colord",
···
"svelte-awesome-slider"
]
},
+
"svelte-awesome-slider@2.0.0_svelte@5.46.1__acorn@8.15.0": {
"integrity": "sha512-YBkOdYm1Feaqsn2JkJBRs+Kc/X3Qy/3GuVmI7GmoYDjBaHkjx9uH4khTuED22z57Hg3gGWeDhp/clIjWDdLNaw==",
"dependencies": [
"svelte"
]
},
+
"svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": {
+
"integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
"dependencies": [
"@jridgewell/trace-mapping",
"chokidar",
···
"tslib"
]
},
+
"svelte-eslint-parser@1.4.1_svelte@5.46.1__acorn@8.15.0_postcss@8.5.6": {
+
"integrity": "sha512-1eqkfQ93goAhjAXxZiu1SaKI9+0/sxp4JIWQwUpsz7ybehRE5L8dNuz7Iry7K22R47p5/+s9EM+38nHV2OlgXA==",
"dependencies": [
"eslint-scope",
"eslint-visitor-keys@4.2.1",
···
"svelte"
]
},
+
"svelte-infinite@0.5.1_svelte@5.46.1__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.46.1_acorn@8.15.0": {
+
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
"dependencies": [
"@jridgewell/remapping",
"@jridgewell/sourcemap-codec",
···
"aria-query",
"axobject-query",
"clsx",
+
"devalue",
"esm-env",
"esrap",
"is-reference",
···
"zimmerframe"
]
},
+
"tailwindcss@4.1.18": {
+
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="
},
"tapable@2.3.0": {
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="
···
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dependencies": [
"fdir",
+
"picomatch"
]
},
"totalist@3.0.1": {
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
+
"ts-api-utils@2.4.0_typescript@5.9.3": {
+
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==",
"dependencies": [
"typescript"
]
···
"prelude-ls"
]
},
+
"type-fest@4.41.0": {
+
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="
+
},
+
"typescript-eslint@8.51.0_eslint@9.39.2_typescript@5.9.3_@typescript-eslint+parser@8.51.0__eslint@9.39.2__typescript@5.9.3": {
+
"integrity": "sha512-jh8ZuM5oEh2PSdyQG9YAEM1TCGuWenLSuSUhf/irbVUNW9O5FhbFVONviN2TgMTBnUmyHv7E56rYnfLZK6TkiA==",
"dependencies": [
"@typescript-eslint/eslint-plugin",
"@typescript-eslint/parser",
···
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true
},
+
"undici-types@6.21.0": {
+
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
+
},
"undici-types@7.16.0": {
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
+
},
+
"unicode-segmenter@0.14.5": {
+
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
},
"uri-js@4.4.1": {
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
···
"util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
+
"vite@7.3.0_@types+node@25.0.3_picomatch@4.0.3": {
+
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dependencies": [
+
"@types/node@25.0.3",
"esbuild",
"fdir",
+
"picomatch",
"postcss",
"rollup",
"tinyglobby"
···
"fsevents"
],
"optionalPeers": [
+
"@types/node@25.0.3"
],
"bin": true
},
+
"vitefu@1.1.1_vite@7.3.0__@types+node@25.0.3__picomatch@4.0.3_@types+node@25.0.3": {
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
"dependencies": [
"vite"
···
"yocto-queue@0.1.0": {
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
},
+
"yocto-queue@1.2.2": {
+
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="
+
},
"zimmerframe@1.1.4": {
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
}
···
"workspace": {
"packageJson": {
"dependencies": [
+
"npm:@atcute/atproto@^3.1.10",
+
"npm:@atcute/bluesky-richtext-builder@^2.0.4",
+
"npm:@atcute/bluesky-richtext-segmenter@^2.0.4",
+
"npm:@atcute/bluesky@^3.2.14",
+
"npm:@atcute/client@^4.2.0",
+
"npm:@atcute/identity-resolver@^1.2.1",
+
"npm:@atcute/identity@^1.1.3",
+
"npm:@atcute/jetstream@^1.1.2",
+
"npm:@atcute/lexicons@^1.2.6",
+
"npm:@atcute/oauth-browser-client@^2.0.3",
+
"npm:@atcute/tid@^1.1.1",
+
"npm:@eslint/compat@2",
+
"npm:@eslint/js@^9.39.2",
"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.49.2",
"npm:@sveltejs/vite-plugin-svelte@^6.2.1",
+
"npm:@tailwindcss/forms@~0.5.11",
+
"npm:@tailwindcss/vite@^4.1.18",
+
"npm:@tutorlatin/svelte-tiny-virtual-list@^3.0.18",
+
"npm:@types/node@^25.0.3",
"npm:@wora/cache-persist@^2.2.1",
+
"npm:async-cache-dedupe@^3.4.0",
"npm:eslint-config-prettier@^10.1.8",
+
"npm:eslint-plugin-svelte@^3.13.1",
+
"npm:eslint@^9.39.2",
+
"npm:globals@17",
"npm:hash-wasm@^4.12.0",
+
"npm:lru-cache@^11.2.4",
+
"npm:photoswipe@^5.4.4",
+
"npm:prettier-plugin-svelte@^3.4.1",
+
"npm:prettier-plugin-tailwindcss@~0.7.2",
+
"npm:prettier@^3.7.4",
"npm:svelte-awesome-color-picker@^4.1.0",
+
"npm:svelte-check@^4.3.5",
"npm:svelte-device-info@^1.0.6",
"npm:svelte-infinite@~0.5.1",
"npm:svelte-portal@^2.2.1",
+
"npm:svelte@^5.46.1",
+
"npm:tailwindcss@^4.1.18",
+
"npm:typescript-eslint@^8.51.0",
"npm:typescript@^5.9.3",
+
"npm:vite@^7.3.0"
]
}
}
+2 -2
nix/modules.nix
···
];
};
-
outputHash = "sha256-s5rq8htDjR0I8MxPtLq1NYIywXGEdYbZZvE7I5+TCIU=";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
-
nativeBuildInputs = [deno];
dontConfigure = true;
dontCheck = true;
···
];
};
+
outputHash = "sha256-1AkU6eV0uIUZohotHhd8E5eAwc4E4wwg2SjHVUdX8LE=";
outputHashAlgo = "sha256";
outputHashMode = "recursive";
+
nativeBuildInputs = [ deno ];
dontConfigure = true;
dontCheck = true;
+32 -26
package.json
···
"lint": "prettier --check . && eslint ."
},
"dependencies": {
-
"@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",
-
"@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.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.16",
-
"@types/node": "^24.10.0",
-
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
-
"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.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"
}
}
···
"lint": "prettier --check . && eslint ."
},
"dependencies": {
+
"@atcute/atproto": "^3.1.10",
+
"@atcute/bluesky": "^3.2.14",
+
"@atcute/bluesky-richtext-builder": "^2.0.4",
+
"@atcute/bluesky-richtext-segmenter": "^2.0.4",
+
"@atcute/client": "^4.2.0",
+
"@atcute/identity": "^1.1.3",
+
"@atcute/identity-resolver": "^1.2.1",
+
"@atcute/jetstream": "^1.1.2",
+
"@atcute/lexicons": "^1.2.6",
+
"@atcute/oauth-browser-client": "^2.0.3",
+
"@atcute/tid": "^1.1.1",
"@floating-ui/dom": "^1.7.4",
"@soffinal/websocket": "^0.2.1",
+
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.18",
"@wora/cache-persist": "^2.2.1",
+
"async-cache-dedupe": "^3.4.0",
"hash-wasm": "^4.12.0",
+
"lru-cache": "^11.2.4",
+
"photoswipe": "^5.4.4",
"svelte-device-info": "^1.0.6",
"svelte-infinite": "^0.5.1",
"svelte-portal": "^2.2.1"
},
"devDependencies": {
+
"@eslint/compat": "^2.0.0",
+
"@eslint/js": "^9.39.2",
"@iconify/svelte": "^5.1.0",
"@sveltejs/adapter-static": "^3.0.10",
+
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
+
"@tailwindcss/forms": "^0.5.11",
+
"@tailwindcss/vite": "^4.1.18",
+
"@types/node": "^25.0.3",
+
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
+
"eslint-plugin-svelte": "^3.13.1",
+
"globals": "^17.0.0",
+
"prettier": "^3.7.4",
+
"prettier-plugin-svelte": "^3.4.1",
+
"prettier-plugin-tailwindcss": "^0.7.2",
+
"svelte": "^5.46.1",
"svelte-awesome-color-picker": "^4.1.0",
+
"svelte-check": "^4.3.5",
+
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
+
"typescript-eslint": "^8.51.0",
+
"vite": "^7.3.0"
}
}
+30
src/app.css
···
box-shadow: 0 0 20px 5px var(--nucleus-selected-post);
}
}
···
box-shadow: 0 0 20px 5px var(--nucleus-selected-post);
}
}
+
+
@keyframes slide-in-from-right {
+
from {
+
transform: translateX(144px);
+
opacity: 0;
+
}
+
to {
+
transform: translateX(0);
+
opacity: 1;
+
}
+
}
+
+
@keyframes slide-in-from-left {
+
from {
+
transform: translateX(-144px);
+
opacity: 0;
+
}
+
to {
+
transform: translateX(0);
+
opacity: 1;
+
}
+
}
+
+
.animate-slide-in-right {
+
animation: slide-in-from-right 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+
}
+
+
.animate-slide-in-left {
+
animation: slide-in-from-left 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
+
}
+2 -1
src/app.html
···
<html lang="en">
<head>
<meta charset="utf-8" />
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
···
<html lang="en">
<head>
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
+
<meta name="theme-color" content="#11001c" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+2 -2
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';
···
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);
···
<script lang="ts">
import { generateColorForDid, loggingIn, type Account } from '$lib/accounts';
+
import { AtpClient, resolveHandle } from '$lib/at/client';
import type { Handle } from '@atcute/lexicons';
import ProfilePicture from './ProfilePicture.svelte';
import PfpPlaceholder from './PfpPlaceholder.svelte';
···
if (isHandle(loginHandle)) handle = loginHandle;
else throw 'handle is invalid';
+
let did = await resolveHandle(handle);
if (!did.ok) throw did.error;
await initiateLogin(did.value, handle);
+157 -376
src/components/BskyPost.svelte
···
<script lang="ts">
-
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;
···
isOnPostComposer?: boolean;
onQuote?: (quote: PostWithUri) => void;
onReply?: (reply: PostWithUri) => void;
}
const {
···
mini,
onQuote,
onReply,
-
isOnPostComposer = false /* replyBacklinks */
}: Props = $props();
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;
···
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 '๐Ÿ”— has external link';
-
case 'app.bsky.embed.record':
-
return '๐Ÿ’ฌ has quote';
-
case 'app.bsky.embed.images':
-
return '๐Ÿ–ผ๏ธ has images';
-
case 'app.bsky.embed.video':
-
return '๐ŸŽฅ has video';
-
case 'app.bsky.embed.recordWithMedia':
-
return '๐Ÿ“Ž has quote with media';
-
default:
-
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 });
···
return;
}
-
actionClient?.atcute
?.post('com.atproto.repo.deleteRecord', {
input: {
collection: 'app.bsky.feed.post',
···
})
.then((result) => {
if (!result.ok) return;
-
posts.get(did)?.delete(aturi);
deleteState = 'deleted';
});
actionsOpen = false;
};
let profileOpen = $state(false);
-
let profilePopoutShowDid = $state(false);
</script>
-
{#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}
···
</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}
···
>
<span style="color: {color};">@{handle}</span>:
{#if record.embed}
-
{@render embedBadge(record.embed)}
{/if}
<span title={record.text}>{record.text}</span>
</div>
···
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}
···
{/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}
-
<!-- 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"
···
{@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',
···
{/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>
···
if (autoClose) actionsOpen = false;
}}
>
-
<span class="font-bold">{label}</span>
<Icon class="h-6 w-6" {icon} />
</button>
{/snippet}
···
<script lang="ts">
+
import { resolveDidDoc, type AtpClient } from '$lib/at/client';
+
import { AppBskyActorProfile, AppBskyEmbedRecord, AppBskyFeedPost } from '@atcute/bluesky';
import {
parseCanonicalResourceUri,
type Did,
+
type Handle,
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 BskyPost from './BskyPost.svelte';
import Icon from '@iconify/svelte';
+
import {
+
allPosts,
+
pulsingPostId,
+
currentTime,
+
deletePostBacklink,
+
createPostBacklink,
+
router,
+
profiles,
+
handles,
+
hasBacklink
+
} from '$lib/state.svelte';
import type { PostWithUri } from '$lib/at/fetch';
+
import { onMount, type Snippet } from 'svelte';
import { derived } from 'svelte/store';
import Dropdown from './Dropdown.svelte';
import { settings } from '$lib/settings';
+
import RichText from './RichText.svelte';
+
import { getRelativeTime } from '$lib/date';
+
import { likeSource, repostSource, toCanonicalUri } from '$lib';
+
import ProfileInfo from './ProfileInfo.svelte';
+
import EmbedBadge from './EmbedBadge.svelte';
+
import EmbedMedia from './EmbedMedia.svelte';
interface Props {
client: AtpClient;
···
isOnPostComposer?: boolean;
onQuote?: (quote: PostWithUri) => void;
onReply?: (reply: PostWithUri) => void;
+
cornerFragment?: Snippet;
}
const {
···
mini,
onQuote,
onReply,
+
isOnPostComposer = false /* replyBacklinks */,
+
cornerFragment
}: Props = $props();
const selectedDid = $derived(client.user?.did ?? null);
+
const isLoggedInUser = $derived($accounts.some((acc) => acc.did === did));
+
const aturi = $derived(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
+
const color = $derived(generateColorForDid(did));
+
let handle: Handle = $state(handles.get(did) ?? 'handle.invalid');
+
onMount(() => {
+
resolveDidDoc(did).then((res) => {
+
if (res.ok) {
+
handle = res.value.handle;
+
handles.set(did, handle);
+
}
+
return res;
+
});
});
const post = data
? Promise.resolve(ok(data))
: client.getRecord(AppBskyFeedPost.mainSchema, did, rkey);
+
let profile: AppBskyActorProfile.Main | null = $state(profiles.get(did) ?? null);
onMount(async () => {
const p = await client.getProfile(did);
if (!p.ok) return;
profile = p.value;
+
profiles.set(did, profile);
});
+
const postId = $derived(
+
`timeline-post-${did.replace(/[^a-zA-Z0-9]/g, '_')}-${rkey}-${quoteDepth}`
+
);
const isPulsing = derived(pulsingPostId, (pulsingPostId) => pulsingPostId === postId);
const scrollToAndPulse = (targetUri: ResourceUri) => {
const targetId = `timeline-post-${targetUri}-0`;
const element = document.getElementById(targetId);
if (!element) return;
···
generateColorForDid(expect(parseCanonicalResourceUri(targetUri)).repo)
);
pulsingPostId.set(targetId);
setTimeout(() => pulsingPostId.set(null), 1200);
}, 400);
};
let actionsOpen = $state(false);
let actionsPos = $state({ x: 0, y: 0 });
···
return;
}
+
client?.atcute
?.post('com.atproto.repo.deleteRecord', {
input: {
collection: 'app.bsky.feed.post',
···
})
.then((result) => {
if (!result.ok) return;
+
allPosts.get(did)?.delete(aturi);
deleteState = 'deleted';
});
actionsOpen = false;
};
let profileOpen = $state(false);
</script>
{#snippet profileInline()}
<button
class="
+
flex min-w-0 items-center gap-2 font-bold {isOnPostComposer ? 'contrast-125' : ''}
rounded-sm pr-1 transition-colors duration-100 ease-in-out hover:bg-white/10
"
style="color: {color};"
+
onclick={() => ((profileOpen = false), router.navigate(`/profile/${did}`))}
>
<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?.length === 0 ? handle : profile.displayName}</span
><span class="shrink-0 text-sm text-nowrap opacity-70">(@{handle})</span>
{:else}
{handle}
···
</button>
{/snippet}
{#snippet profilePopout()}
<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}
+
onMouseEnter={() => (profileOpen = true)}
+
onMouseLeave={() => (profileOpen = false)}
>
+
<ProfileInfo {client} {did} {handle} {profile} />
</Dropdown>
{/snippet}
···
>
<span style="color: {color};">@{handle}</span>:
{#if record.embed}
+
<EmbedBadge embed={record.embed} />
{/if}
<span title={record.text}>{record.text}</span>
</div>
···
border-color: {color}{isOnPostComposer ? '99' : '66'};
"
>
+
<div class="mb-3 flex max-w-full items-center justify-between">
+
<div class="flex 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), currentTime)}
+
</span>
+
</div>
+
{@render cornerFragment?.()}
</div>
+
<p class="leading-normal text-wrap wrap-break-word">
+
<RichText text={record.text} facets={record.facets ?? []} />
{#if isOnPostComposer && record.embed}
+
<EmbedBadge embed={record.embed} {color} />
{/if}
</p>
{#if !isOnPostComposer && record.embed}
{@const embed = record.embed}
<div class="mt-2">
+
{#if embed.$type === 'app.bsky.embed.images' || embed.$type === 'app.bsky.embed.video'}
+
<EmbedMedia {did} {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">
+
<EmbedMedia {did} embed={embed.media} />
+
{@render embedPost(embed.record.record.uri)}
+
</div>
+
{/if}
</div>
{/if}
{#if !isOnPostComposer}
+
{@render postControls(post.value)}
{/if}
</div>
{:else}
···
{/await}
{/if}
+
{#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}
+
<EmbedBadge embed={{ $type: 'app.bsky.embed.record' } as AppBskyEmbedRecord.Main} />
{/if}
{/snippet}
+
{#snippet postControls(post: PostWithUri)}
+
{@const myRepost = hasBacklink(post.uri, repostSource, selectedDid!)}
+
{@const myLike = hasBacklink(post.uri, likeSource, selectedDid!)}
+
{#snippet control({
+
name,
+
icon,
+
onClick,
+
isFull,
+
hasSolid,
+
canBeDisabled = true,
+
iconColor = color
+
}: {
+
name: string;
+
icon: string;
+
onClick: (e: MouseEvent) => void;
+
isFull?: boolean;
+
hasSolid?: boolean;
+
canBeDisabled?: boolean;
+
iconColor?: string;
+
})}
<button
class="
+
px-1.75 py-1.5 text-(--nucleus-fg)/90 transition-all
+
duration-100 not-disabled:hover:[backdrop-filter:brightness(120%)]
+
disabled:cursor-not-allowed!
"
onclick={(e) => onClick(e)}
+
style="color: {isFull ? iconColor : 'color-mix(in srgb, var(--nucleus-fg) 90%, transparent)'}"
title={name}
+
disabled={canBeDisabled ? selectedDid === null : false}
>
<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;">
+
{@render control({
+
name: 'reply',
+
icon: 'heroicons:chat-bubble-left',
+
hasSolid: true,
+
onClick: () => onReply?.(post)
})}
+
{@render control({
+
name: 'repost',
+
icon: 'heroicons:arrow-path-rounded-square-20-solid',
+
onClick: () => {
+
if (!selectedDid) return;
+
if (myRepost) deletePostBacklink(client, post, repostSource);
+
else createPostBacklink(client, post, repostSource);
},
+
isFull: myRepost
+
})}
+
{@render control({
+
name: 'quote',
+
icon: 'heroicons:paper-clip-20-solid',
+
onClick: () => onQuote?.(post)
})}
+
{@render control({
+
name: 'like',
+
icon: 'heroicons:star',
+
onClick: () => {
+
if (!selectedDid) return;
+
if (myLike) deletePostBacklink(client, post, likeSource);
+
else createPostBacklink(client, post, likeSource);
},
+
isFull: myLike,
+
hasSolid: true
+
})}
</div>
<Dropdown
class="post-dropdown"
···
{@render dropdownItem('heroicons:clipboard-20-solid', 'copy post text', () =>
navigator.clipboard.writeText(post.record.text)
)}
+
{#if isLoggedInUser}
<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',
···
{/if}
{#snippet trigger()}
+
{@render control({
+
name: 'actions',
+
icon: 'heroicons:ellipsis-horizontal-16-solid',
+
onClick: (e: MouseEvent) => {
e.stopPropagation();
actionsOpen = !actionsOpen;
actionsPos = { x: 0, y: 0 };
+
},
+
canBeDisabled: false,
+
isFull: true,
+
iconColor: 'color-mix(in srgb, var(--nucleus-fg) 70%, transparent)'
+
})}
{/snippet}
</Dropdown>
</div>
···
if (autoClose) actionsOpen = false;
}}
>
+
<span class="font-semibold opacity-85">{label}</span>
<Icon class="h-6 w-6" {icon} />
</button>
{/snippet}
+71 -5
src/components/Dropdown.svelte
···
children?: import('svelte').Snippet;
placement?: Placement;
offsetDistance?: number;
position?: { x: number; y: number };
}
let {
···
children,
placement = 'bottom-start',
offsetDistance = 2,
position = $bindable(),
...restProps
}: Props = $props();
···
let contentRef: HTMLElement | undefined = $state();
let cleanup: (() => void) | null = null;
const updatePosition = async () => {
const { x, y } = await computePosition(triggerRef!, contentRef!, {
placement,
···
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 handleScroll = handleClose;
$effect(() => {
if (isOpen) {
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
···
}
});
-
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>
···
style={restProps.style}
role="menu"
tabindex="-1"
>
{@render children?.()}
</div>
···
children?: import('svelte').Snippet;
placement?: Placement;
offsetDistance?: number;
+
openDelay?: number;
position?: { x: number; y: number };
+
onMouseEnter?: () => void;
+
onMouseLeave?: () => void;
}
let {
···
children,
placement = 'bottom-start',
offsetDistance = 2,
+
openDelay = 400,
position = $bindable(),
+
onMouseEnter,
+
onMouseLeave,
...restProps
}: Props = $props();
···
let contentRef: HTMLElement | undefined = $state();
let cleanup: (() => void) | null = null;
+
let isTriggerHovered = false;
+
let isContentHovered = false;
+
let closeTimer: ReturnType<typeof setTimeout>;
+
let openTimer: ReturnType<typeof setTimeout>;
+
const updatePosition = async () => {
const { x, y } = await computePosition(triggerRef!, contentRef!, {
placement,
···
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 handleScroll = handleClose;
+
// The central check: "Should we close now?"
+
const scheduleCloseCheck = () => {
+
clearTimeout(closeTimer);
+
closeTimer = setTimeout(() => {
+
// Only close if we are NOT on the trigger AND NOT on the content
+
if (!isTriggerHovered && !isContentHovered) if (isOpen && onMouseLeave) onMouseLeave();
+
}, 30); // Small buffer to handle the physical gap between elements
+
};
+
+
const handleTriggerEnter = () => {
+
isTriggerHovered = true;
+
clearTimeout(closeTimer);
+
+
if (!isOpen) {
+
clearTimeout(openTimer);
+
openTimer = setTimeout(() => {
+
if (onMouseEnter) onMouseEnter();
+
}, openDelay);
+
}
+
};
+
+
const handleTriggerLeave = () => {
+
isTriggerHovered = false;
+
clearTimeout(openTimer);
+
scheduleCloseCheck(); // We left the trigger, check if we should close
+
};
+
+
const handleContentEnter = () => {
+
isContentHovered = true;
+
clearTimeout(closeTimer); // We made it to the content, cancel close
+
};
+
+
const handleContentLeave = () => {
+
isContentHovered = false;
+
scheduleCloseCheck(); // We left the content, check if we should close
+
};
+
+
// Reset state if the menu is closed externally
+
$effect(() => {
+
if (!isOpen) {
+
isContentHovered = false;
+
clearTimeout(closeTimer);
+
clearTimeout(openTimer); // Ensure open timer is cleared on external close
+
}
+
});
+
$effect(() => {
if (isOpen) {
cleanup = autoUpdate(triggerRef!, contentRef!, updatePosition);
···
}
});
+
onMount(() => () => {
+
if (cleanup) cleanup();
+
clearTimeout(closeTimer);
+
clearTimeout(openTimer); // Cleanup open timer on unmount
});
</script>
<svelte:window onkeydown={handleEscape} onmousedown={handleClickOutside} onscroll={handleScroll} />
+
<div
+
role="button"
+
tabindex="0"
+
bind:this={triggerRef}
+
onmouseenter={handleTriggerEnter}
+
onmouseleave={handleTriggerLeave}
+
>
{@render trigger?.()}
</div>
···
style={restProps.style}
role="menu"
tabindex="-1"
+
onmouseenter={handleContentEnter}
+
onmouseleave={handleContentLeave}
>
{@render children?.()}
</div>
+37
src/components/EmbedBadge.svelte
···
···
+
<script lang="ts">
+
import type { AppBskyEmbeds } from '$lib/at/types';
+
+
interface Props {
+
embed: AppBskyEmbeds;
+
color?: string;
+
}
+
+
let { embed, color = 'var(--nucleus-fg)' }: Props = $props();
+
+
const embedText = $derived.by(() => {
+
switch (embed.$type) {
+
case 'app.bsky.embed.external':
+
return '๐Ÿ”— has external link';
+
case 'app.bsky.embed.record':
+
return '๐Ÿ’ฌ has quote';
+
case 'app.bsky.embed.images':
+
return '๐Ÿ–ผ๏ธ has images';
+
case 'app.bsky.embed.video':
+
return '๐ŸŽฅ has video';
+
case 'app.bsky.embed.recordWithMedia':
+
return '๐Ÿ“Ž has quote with media';
+
default:
+
return 'โ“ has unknown embed';
+
}
+
});
+
</script>
+
+
<span
+
class="rounded-full px-2.5 py-0.5 text-xs font-medium"
+
style="
+
background: color-mix(in srgb, {color} 10%, transparent);
+
color: {color};
+
"
+
>
+
{embedText}
+
</span>
+56
src/components/EmbedMedia.svelte
···
···
+
<script lang="ts">
+
import { isBlob } from '@atcute/lexicons/interfaces';
+
import PhotoSwipeGallery, { type GalleryItem } from './PhotoSwipeGallery.svelte';
+
import { blob, img } from '$lib/cdn';
+
import { type Did } from '@atcute/lexicons';
+
import { resolveDidDoc } from '$lib/at/client';
+
import type { AppBskyEmbedMedia } from '$lib/at/types';
+
+
interface Props {
+
did: Did;
+
embed: AppBskyEmbedMedia;
+
}
+
+
let { did, embed }: Props = $props();
+
</script>
+
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div oncontextmenu={(e) => e.stopPropagation()}>
+
{#if embed.$type === 'app.bsky.embed.images'}
+
{@const _images = embed.images.flatMap((img) =>
+
isBlob(img.image) ? [{ ...img, image: img.image }] : []
+
)}
+
{@const images = _images.map((i): GalleryItem => {
+
const sizeFactor = 200;
+
const size = {
+
width: (i.aspectRatio?.width ?? 4) * sizeFactor,
+
height: (i.aspectRatio?.height ?? 3) * sizeFactor
+
};
+
const cid = i.image.ref.$link;
+
return {
+
...size,
+
src: img('feed_fullsize', did, cid),
+
thumbnail: {
+
src: img('feed_thumbnail', did, cid),
+
...size
+
}
+
};
+
})}
+
{#if images.length > 0}
+
<PhotoSwipeGallery {images} />
+
{/if}
+
{:else if embed.$type === 'app.bsky.embed.video'}
+
{#if isBlob(embed.video)}
+
{#await resolveDidDoc(did) 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>
+141
src/components/FollowingItem.svelte
···
···
+
<script lang="ts" module>
+
// Cache for synchronous access during component recycling
+
const profileCache = new SvelteMap<string, { displayName?: string; handle: string }>();
+
</script>
+
+
<script lang="ts">
+
import ProfilePicture from './ProfilePicture.svelte';
+
import { getRelativeTime } from '$lib/date';
+
import { generateColorForDid } from '$lib/accounts';
+
import type { Did } from '@atcute/lexicons';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import type { calculateFollowedUserStats, Sort } from '$lib/following';
+
import type { AtpClient } from '$lib/at/client';
+
import { SvelteMap } from 'svelte/reactivity';
+
import { clients, getClient, router } from '$lib/state.svelte';
+
+
interface Props {
+
style: string;
+
did: Did;
+
stats: NonNullable<ReturnType<typeof calculateFollowedUserStats>>;
+
client: AtpClient;
+
sort: Sort;
+
currentTime: Date;
+
}
+
+
let { style, did, stats, client, sort, currentTime }: Props = $props();
+
+
// svelte-ignore state_referenced_locally
+
const cached = profileCache.get(did);
+
let displayName = $state<string | undefined>(cached?.displayName);
+
let handle = $state<string>(cached?.handle ?? 'handle.invalid');
+
+
const loadProfile = async (targetDid: Did) => {
+
if (profileCache.has(targetDid)) {
+
const c = profileCache.get(targetDid)!;
+
displayName = c.displayName;
+
handle = c.handle;
+
} else {
+
const existingClient = clients.get(targetDid as AtprotoDid);
+
if (existingClient?.user?.handle) {
+
handle = existingClient.user.handle;
+
} else {
+
handle = 'handle.invalid';
+
displayName = undefined;
+
}
+
}
+
+
try {
+
// Optimization: Check clients map first to avoid async overhead if possible
+
// but we need to ensure we have the profile data, not just client existence.
+
const userClient = await getClient(targetDid as AtprotoDid);
+
+
// Check if the component has been recycled for a different user while we were awaiting
+
if (did !== targetDid) return;
+
+
let newHandle = handle;
+
let newDisplayName = displayName;
+
+
if (userClient.user?.handle) {
+
newHandle = userClient.user.handle;
+
handle = newHandle;
+
} else {
+
newHandle = targetDid;
+
handle = newHandle;
+
}
+
+
const profileRes = await userClient.getProfile();
+
+
if (did !== targetDid) return;
+
+
if (profileRes.ok) {
+
newDisplayName = profileRes.value.displayName;
+
displayName = newDisplayName;
+
}
+
+
// Update cache
+
profileCache.set(targetDid, {
+
handle: newHandle,
+
displayName: newDisplayName
+
});
+
} catch (e) {
+
if (did !== targetDid) return;
+
console.error(`failed to load profile for ${targetDid}`, e);
+
handle = 'error';
+
}
+
};
+
+
// Re-run whenever `did` changes
+
$effect(() => {
+
loadProfile(did);
+
});
+
+
const lastPostAt = $derived(stats?.lastPostAt ?? new Date(0));
+
const relTime = $derived(getRelativeTime(lastPostAt, currentTime));
+
const color = $derived(generateColorForDid(did));
+
+
const goToProfile = () => {
+
router.navigate(`/profile/${did}`);
+
};
+
</script>
+
+
<div {style} class="box-border w-full pb-2">
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
onclick={goToProfile}
+
class="group flex cursor-pointer items-center gap-2 rounded-sm bg-(--nucleus-accent)/7 p-3 transition-colors hover:bg-(--post-color)/20"
+
style={`--post-color: ${color};`}
+
>
+
<ProfilePicture {client} {did} size={10} />
+
<div class="min-w-0 flex-1 space-y-1">
+
<div
+
class="flex items-baseline gap-2 font-bold transition-colors group-hover:text-(--post-color)"
+
style={`--post-color: ${color};`}
+
>
+
<span class="truncate">{displayName || handle}</span>
+
<span class="truncate text-sm opacity-60">@{handle}</span>
+
</div>
+
<div class="flex gap-2 text-xs opacity-70">
+
<span
+
class={Date.now() - lastPostAt.getTime() < 1000 * 60 * 60 * 2
+
? 'text-(--nucleus-accent)'
+
: ''}
+
>
+
posted {relTime}
+
{relTime !== 'now' ? 'ago' : ''}
+
</span>
+
{#if stats?.recentPostCount && stats.recentPostCount > 0}
+
<span class="text-(--nucleus-accent2)">
+
{stats.recentPostCount} posts / 6h
+
</span>
+
{/if}
+
{#if sort === 'conversational' && stats?.conversationalScore && stats.conversationalScore > 0}
+
<span class="ml-auto font-bold text-(--nucleus-accent)">
+
โ˜… {stats.conversationalScore.toFixed(1)}
+
</span>
+
{/if}
+
</div>
+
</div>
+
</div>
+
</div>
+167
src/components/FollowingView.svelte
···
···
+
<script lang="ts">
+
import { follows, allPosts, allBacklinks, currentTime, replyIndex } from '$lib/state.svelte';
+
import type { Did } from '@atcute/lexicons';
+
import { type AtpClient } from '$lib/at/client';
+
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list';
+
import {
+
calculateFollowedUserStats,
+
calculateInteractionScores,
+
sortFollowedUser,
+
type Sort
+
} from '$lib/following';
+
import FollowingItem from './FollowingItem.svelte';
+
import NotLoggedIn from './NotLoggedIn.svelte';
+
+
interface Props {
+
client: AtpClient | undefined;
+
followingSort: Sort;
+
}
+
+
let { client, followingSort = $bindable('active') }: Props = $props();
+
+
const selectedDid = $derived(client?.user?.did);
+
const followsMap = $derived(selectedDid ? follows.get(selectedDid) : undefined);
+
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
let sortedFollowing = $state<{ did: Did; data: any }[]>([]);
+
+
let isLongCalculation = $state(false);
+
let calculationTimer: ReturnType<typeof setTimeout> | undefined;
+
+
// we could update the "now" every second but its pretty unnecessary
+
// so we only do it when we receive new data or sort mode changes
+
let staticNow = $state(Date.now());
+
+
const updateList = async () => {
+
// Reset timer and loading state at start
+
if (calculationTimer) clearTimeout(calculationTimer);
+
isLongCalculation = false;
+
+
if (!followsMap || !selectedDid) {
+
sortedFollowing = [];
+
return;
+
}
+
+
// schedule spinner to appear only if calculation takes > 200ms
+
calculationTimer = setTimeout(() => (isLongCalculation = true), 200);
+
// yield to main thread to allow UI to show spinner/update
+
await new Promise((resolve) => setTimeout(resolve, 0));
+
+
const interactionScores =
+
followingSort === 'conversational'
+
? calculateInteractionScores(
+
selectedDid,
+
followsMap,
+
allPosts,
+
allBacklinks,
+
replyIndex,
+
staticNow
+
)
+
: null;
+
+
const userStatsList = followsMap.values().map((f) => ({
+
did: f.subject,
+
data: calculateFollowedUserStats(
+
followingSort,
+
f.subject,
+
allPosts,
+
interactionScores,
+
staticNow
+
)
+
}));
+
+
const following = userStatsList.filter((u) => u.data !== null);
+
const sorted = [...following].sort((a, b) => sortFollowedUser(followingSort, a.data!, b.data!));
+
+
sortedFollowing = sorted;
+
+
// Clear timer and remove loading state immediately after done
+
if (calculationTimer) clearTimeout(calculationTimer);
+
isLongCalculation = false;
+
};
+
+
// todo: there is a bug where the view doesn't update and just gets stuck being loaded
+
$effect(() => {
+
// Dependencies that trigger a re-sort
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
const _s = followingSort;
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
const _f = followsMap?.size;
+
// Update time when sort changes
+
staticNow = Date.now();
+
+
updateList();
+
});
+
+
let listHeight = $state(0);
+
let listContainer: HTMLDivElement | undefined = $state();
+
+
const calcHeight = () => {
+
if (!listContainer) return;
+
const footer = document.getElementById('app-footer');
+
const footerHeight = footer?.getBoundingClientRect().height || 0;
+
const top = listContainer.getBoundingClientRect().top;
+
// 24px is our bottom padding
+
listHeight = Math.max(0, window.innerHeight - top - footerHeight - 24);
+
};
+
+
$effect(() => {
+
if (listContainer) {
+
calcHeight();
+
const observer = new ResizeObserver(calcHeight);
+
observer.observe(document.body);
+
return () => observer.disconnect();
+
}
+
});
+
</script>
+
+
<div class="flex h-full flex-col p-2">
+
<div class="mb-4 flex items-center justify-between gap-2 p-2 px-2 md:gap-4">
+
<div>
+
<h2 class="text-2xl font-bold md:text-3xl">following</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-11 rounded-full bg-(--nucleus-accent2)"></div>
+
</div>
+
</div>
+
<div class="flex gap-1 text-sm sm:gap-2">
+
{#each ['recent', 'active', 'conversational'] as type (type)}
+
<button
+
class="rounded-sm px-2 py-1 transition-colors {followingSort === type
+
? 'bg-(--nucleus-accent) text-(--nucleus-bg)'
+
: 'bg-(--nucleus-accent)/10 hover:bg-(--nucleus-accent)/20'}"
+
onclick={() => (followingSort = type as Sort)}
+
>
+
{type}
+
</button>
+
{/each}
+
</div>
+
</div>
+
+
<div class="min-h-0 flex-1" bind:this={listContainer}>
+
{#if !client}
+
<NotLoggedIn />
+
{:else if sortedFollowing.length === 0 || isLongCalculation}
+
<div class="flex justify-center py-8">
+
<div
+
class="h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
+
></div>
+
</div>
+
{:else if listHeight > 0}
+
<VirtualList height={listHeight} itemCount={sortedFollowing.length} itemSize={76}>
+
{#snippet item({ index, style }: { index: number; style: string })}
+
{@const user = sortedFollowing[index]}
+
<FollowingItem
+
{style}
+
did={user.did}
+
stats={user.data!}
+
{client}
+
sort={followingSort}
+
{currentTime}
+
/>
+
{/snippet}
+
</VirtualList>
+
{/if}
+
</div>
+
</div>
+5
src/components/NotLoggedIn.svelte
···
···
+
<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>
-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>
···
+18
src/components/NotificationsView.svelte
···
···
+
<div class="p-4">
+
<div class="mb-6">
+
<h2 class="text-3xl font-bold">notifications</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>
+
+
<div
+
class="flex h-64 items-center justify-center rounded-sm border-2 border-dashed border-(--nucleus-fg)/10"
+
>
+
<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>
+
</div>
+200
src/components/PhotoSwipeGallery.svelte
···
···
+
<script context="module" lang="ts">
+
export interface GalleryItem {
+
src: string;
+
thumbnail?: {
+
src: string;
+
width: number;
+
height: number;
+
};
+
width: number;
+
height: number;
+
cropped?: boolean;
+
alt?: string;
+
}
+
export type GalleryData = Array<GalleryItem>;
+
</script>
+
+
<script lang="ts">
+
import 'photoswipe/photoswipe.css';
+
import PhotoSwipeLightbox from 'photoswipe/lightbox';
+
import PhotoSwipe, { type ElementProvider, type PreparedPhotoSwipeOptions } from 'photoswipe';
+
import { onMount } from 'svelte';
+
import { writable } from 'svelte/store';
+
+
export let images: GalleryData;
+
let element: HTMLDivElement;
+
+
const options = writable<Partial<PreparedPhotoSwipeOptions> | undefined>(undefined);
+
$: {
+
if (!element) break $;
+
const opts: Partial<PreparedPhotoSwipeOptions> = {
+
pswpModule: PhotoSwipe,
+
children: element.childNodes as ElementProvider,
+
gallery: element,
+
hideAnimationDuration: 0,
+
showAnimationDuration: 0,
+
zoomAnimationDuration: 200,
+
zoomSVG:
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M6.25 8.75v-1h-1a.75.75 0 0 1 0-1.5h1v-1a.75.75 0 0 1 1.5 0v1h1a.75.75 0 0 1 0 1.5h-1v1a.75.75 0 0 1-1.5 0"/><path fill="currentColor" fill-rule="evenodd" d="M7 12c1.11 0 2.136-.362 2.965-.974l2.755 2.754a.75.75 0 1 0 1.06-1.06l-2.754-2.755A5 5 0 1 0 7 12m0-1.5a3.5 3.5 0 1 0 0-7a3.5 3.5 0 0 0 0 7" clip-rule="evenodd"/></svg>',
+
closeSVG:
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94z"/></svg>',
+
arrowPrevSVG:
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.78 4.22a.75.75 0 0 1 0 1.06L7.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L5.47 8.53a.75.75 0 0 1 0-1.06l3.25-3.25a.75.75 0 0 1 1.06 0" clip-rule="evenodd"/></svg>',
+
arrowNextSVG:
+
'<svg class="gallery--icon" xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M6.22 4.22a.75.75 0 0 1 1.06 0l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.75.75 0 0 1-1.06-1.06L8.94 8L6.22 5.28a.75.75 0 0 1 0-1.06" clip-rule="evenodd"/></svg>'
+
};
+
$options = opts;
+
}
+
+
onMount(() => {
+
let lightbox: PhotoSwipeLightbox | undefined;
+
const unsub = options.subscribe((opts) => {
+
lightbox?.destroy?.();
+
if (opts === undefined) return;
+
lightbox = new PhotoSwipeLightbox(opts);
+
lightbox.init();
+
});
+
return () => {
+
unsub();
+
lightbox?.destroy?.();
+
};
+
});
+
</script>
+
+
<div class="gallery styling-twitter" data-total={images.length} bind:this={element}>
+
{#each images as img, i (img.src)}
+
{@const thumb = img.thumbnail ?? img}
+
{@const isHidden = i > 3}
+
{@const isOverlay = i === 3 && images.length > 4}
+
+
<a
+
href={img.src}
+
data-pswp-width={img.width}
+
data-pswp-height={img.height}
+
target="_blank"
+
class:hidden-in-grid={isHidden}
+
class:overlay-container={isOverlay}
+
>
+
<img src={thumb.src} alt={img.alt ?? ''} width={thumb.width} height={thumb.height} />
+
+
{#if isOverlay}
+
<div class="more-overlay">
+
+{images.length - 4}
+
</div>
+
{/if}
+
</a>
+
{/each}
+
</div>
+
+
<style>
+
:global(.gallery--icon) {
+
--drop-color: color-mix(in srgb, var(--color-gray-900) 70%, transparent);
+
color: var(--nucleus-fg);
+
filter: drop-shadow(2px 2px 1px var(--drop-color)) drop-shadow(-2px -2px 1px var(--drop-color))
+
drop-shadow(-2px 2px 1px var(--drop-color)) drop-shadow(2px -2px 1px var(--drop-color));
+
}
+
+
/* --- Default Grid (for 2+ images) --- */
+
.gallery.styling-twitter {
+
display: grid;
+
gap: 2px;
+
border-radius: 4px;
+
overflow: hidden;
+
width: fit-content;
+
}
+
+
.gallery.styling-twitter > a {
+
width: 100%;
+
height: 100%;
+
display: block;
+
position: relative;
+
overflow: hidden;
+
}
+
+
.gallery.styling-twitter > a > img {
+
@apply transition-opacity duration-200 hover:opacity-80;
+
width: 100%;
+
height: 100%;
+
object-fit: cover; /* Standard tile crop */
+
}
+
+
/* --- SINGLE IMAGE OVERRIDES --- */
+
/* This configuration allows the image to determine the width/height
+
naturally based on aspect ratio, up to a max-height limit.
+
*/
+
.gallery.styling-twitter[data-total='1'] {
+
display: block; /* Remove grid constraints */
+
height: auto;
+
width: fit-content;
+
aspect-ratio: auto; /* Remove 16:9 ratio */
+
border-radius: 0;
+
}
+
+
.gallery.styling-twitter[data-total='1'] > a {
+
/* fit-content is key: the container shrinks to fit the image width */
+
width: fit-content;
+
height: auto;
+
display: block;
+
border-radius: 4px;
+
overflow: hidden;
+
max-width: 100%; /* Prevent overflowing the parent */
+
}
+
+
.gallery.styling-twitter[data-total='1'] > a > img {
+
/* Let dimensions flow naturally */
+
width: auto;
+
height: auto;
+
+
/* Constraints: */
+
max-width: 100%; /* Never wider than container */
+
max-height: 60vh; /* Never taller than 60% of viewport (adjust if needed) */
+
+
object-fit: contain; /* Never crop the single image */
+
}
+
+
/* --- Grid Layouts (2+ Images) --- */
+
/* These retain the standard grid look */
+
+
/* 2 Images: Split vertically */
+
.gallery.styling-twitter[data-total='2'] {
+
grid-template-columns: 1fr 1fr;
+
grid-template-rows: 1fr;
+
aspect-ratio: 16/9;
+
}
+
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
+
.gallery.styling-twitter[data-total='3'] {
+
grid-template-columns: 1fr 1fr;
+
grid-template-rows: 1fr 1fr;
+
aspect-ratio: 16/9;
+
}
+
.gallery.styling-twitter[data-total='3'] > a:first-child {
+
grid-row: span 2;
+
}
+
+
/* 4+ Images: 2x2 Grid */
+
.gallery.styling-twitter[data-total='4'],
+
.gallery.styling-twitter[data-total^='5'],
+
.gallery.styling-twitter:not([data-total='1']):not([data-total='2']):not([data-total='3']) {
+
grid-template-columns: 1fr 1fr;
+
grid-template-rows: 1fr 1fr;
+
aspect-ratio: 16/9;
+
}
+
+
.gallery.styling-twitter .hidden-in-grid {
+
display: none;
+
}
+
+
.more-overlay {
+
position: absolute;
+
inset: 0;
+
background-color: rgba(0, 0, 0, 0.5);
+
color: white;
+
display: flex;
+
align-items: center;
+
justify-content: center;
+
font-size: 2rem;
+
font-weight: bold;
+
pointer-events: none;
+
}
+
</style>
+542 -105
src/components/PostComposer.svelte
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
import { ok, err, type Result, expect } from '$lib/result';
-
import type { AppBskyFeedPost } from '@atcute/bluesky';
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;
}
-
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<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()
};
···
}
});
-
if (!res) {
-
return err('failed to post: not logged in');
-
}
-
if (!res.ok) {
return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
-
}
return ok({
uri: res.data.uri,
···
});
};
-
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>
-
{#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
-
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}
···
<!-- 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' : ''}
···
: `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>
-
<!-- TODO: this fucking blows -->
<style>
@reference "../app.css";
···
}
.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>
···
<script lang="ts">
import type { AtpClient } from '$lib/at/client';
import { ok, err, type Result, expect } from '$lib/result';
+
import type { AppBskyEmbedRecordWithMedia, AppBskyFeedPost } from '@atcute/bluesky';
import { generateColorForDid } from '$lib/accounts';
import type { PostWithUri } from '$lib/at/fetch';
import BskyPost from './BskyPost.svelte';
+
import { parseCanonicalResourceUri, type Blob as AtpBlob } from '@atcute/lexicons';
import type { ComAtprotoRepoStrongRef } from '@atcute/atproto';
+
import { parseToRichText } from '$lib/richtext';
+
import { tokenize } from '$lib/richtext/parser';
+
import Icon from '@iconify/svelte';
+
import ProfilePicture from './ProfilePicture.svelte';
+
import type { AppBskyEmbedMedia } from '$lib/at/types';
+
import { SvelteMap } from 'svelte/reactivity';
+
import { handles } from '$lib/state.svelte';
+
+
type UploadState =
+
| { state: 'uploading'; progress: number }
+
| { state: 'uploaded'; blob: AtpBlob<string> }
+
| { state: 'error'; message: string };
+
export type FocusState = 'null' | 'focused';
+
export type State = {
+
focus: FocusState;
+
text: string;
+
quoting?: PostWithUri;
+
replying?: PostWithUri;
+
attachedMedia?: AppBskyEmbedMedia;
+
blobsState: SvelteMap<string, UploadState>;
+
};
interface Props {
client: AtpClient;
onPostSent: (post: PostWithUri) => void;
+
_state: State;
}
+
let { client, onPostSent, _state = $bindable() }: Props = $props();
+
const isFocused = $derived(_state.focus === 'focused');
+
+
const color = $derived(
client.user?.did ? generateColorForDid(client.user?.did) : 'var(--nucleus-accent2)'
);
+
const uploadVideo = async (blobUrl: string, mimeType: string) => {
+
const blob = await (await fetch(blobUrl)).blob();
+
return await client.uploadVideo(blob, mimeType, (status) => {
+
if (status.stage === 'uploading' && status.progress !== undefined) {
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress: status.progress * 0.5 });
+
} else if (status.stage === 'processing' && status.progress !== undefined) {
+
_state.blobsState.set(blobUrl, {
+
state: 'uploading',
+
progress: 0.5 + status.progress * 0.5
+
});
+
}
+
});
+
};
+
const uploadImage = async (blobUrl: string) => {
+
const blob = await (await fetch(blobUrl)).blob();
+
return await client.uploadBlob(blob, (progress) => {
+
_state.blobsState.set(blobUrl, { state: 'uploading', progress });
+
});
+
};
+
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 rt = await parseToRichText(text);
+
+
let media: AppBskyEmbedMedia | undefined = _state.attachedMedia;
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.images') {
+
const images = _state.attachedMedia.images;
+
let uploadedImages: typeof images = [];
+
for (const image of images) {
+
const upload = _state.blobsState.get((image.image as AtpBlob<string>).ref.$link);
+
if (!upload || upload.state !== 'uploaded') continue;
+
uploadedImages.push({
+
...image,
+
image: upload.blob
+
});
+
}
+
if (uploadedImages.length > 0)
+
media = {
+
..._state.attachedMedia,
+
$type: 'app.bsky.embed.images',
+
images: uploadedImages
+
};
+
} else if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
+
const upload = _state.blobsState.get(
+
(_state.attachedMedia.video as AtpBlob<string>).ref.$link
+
);
+
if (upload && upload.state === 'uploaded')
+
media = {
+
..._state.attachedMedia,
+
$type: 'app.bsky.embed.video',
+
video: upload.blob
+
};
+
}
+
console.log('media', media);
+
const record: AppBskyFeedPost.Main = {
$type: 'app.bsky.feed.post',
+
text: rt.text,
+
facets: rt.facets,
+
reply:
+
_state.focus === 'focused' && _state.replying
+
? {
+
root: _state.replying.record.reply?.root ?? strongRef(_state.replying),
+
parent: strongRef(_state.replying)
+
}
+
: undefined,
+
embed:
+
_state.focus === 'focused' && _state.quoting
+
? media
+
? {
+
$type: 'app.bsky.embed.recordWithMedia',
+
record: { record: strongRef(_state.quoting) },
+
media: media as AppBskyEmbedRecordWithMedia.Main['media']
+
}
+
: {
+
$type: 'app.bsky.embed.record',
+
record: strongRef(_state.quoting)
+
}
+
: (media as AppBskyFeedPost.Main['embed']),
createdAt: new Date().toISOString()
};
···
}
});
+
if (!res) return err('failed to post: not logged in');
+
if (!res.ok)
return err(`failed to post: ${res.data.error}: ${res.data.message ?? 'no details'}`);
return ok({
uri: res.data.uri,
···
});
};
+
let posting = $state(false);
+
let postError = $state('');
let textareaEl: HTMLTextAreaElement | undefined = $state();
+
let fileInputEl: HTMLInputElement | undefined = $state();
+
let selectingFile = $state(false);
+
const unfocus = () => (_state.focus = 'null');
+
+
const handleFileSelect = (event: Event) => {
+
selectingFile = false;
+
+
const input = event.target as HTMLInputElement;
+
const files = input.files;
+
if (!files || files.length === 0) return;
+
+
const existingImages =
+
_state.attachedMedia?.$type === 'app.bsky.embed.images' ? _state.attachedMedia.images : [];
+
+
let newImages = [...existingImages];
+
let hasVideo = false;
+
+
for (let i = 0; i < files.length; i++) {
+
const file = files[i];
+
const isVideo = file.type.startsWith('video/');
+
const isImage = file.type.startsWith('image/');
+
+
if (!isVideo && !isImage) {
+
postError = 'unsupported file type';
+
continue;
+
}
+
+
if (isVideo) {
+
if (existingImages.length > 0 || newImages.length > 0) {
+
postError = 'cannot mix images and video';
+
continue;
+
}
+
const blobUrl = URL.createObjectURL(file);
+
_state.attachedMedia = {
+
$type: 'app.bsky.embed.video',
+
video: {
+
$type: 'blob',
+
ref: { $link: blobUrl },
+
mimeType: file.type,
+
size: file.size
+
}
+
};
+
hasVideo = true;
+
break;
+
} else if (isImage) {
+
if (newImages.length >= 4) {
+
postError = 'max 4 images allowed';
+
break;
+
}
+
const blobUrl = URL.createObjectURL(file);
+
newImages.push({
+
image: {
+
$type: 'blob',
+
ref: { $link: blobUrl },
+
mimeType: file.type,
+
size: file.size
+
},
+
alt: '',
+
aspectRatio: undefined
+
});
+
}
+
}
+
+
if (!hasVideo && newImages.length > 0) {
+
_state.attachedMedia = {
+
$type: 'app.bsky.embed.images',
+
images: newImages
+
};
+
}
+
+
const handleUpload = (blobUrl: string, blob: Result<AtpBlob<string>, string>) => {
+
if (blob.ok) _state.blobsState.set(blobUrl, { state: 'uploaded', blob: blob.value });
+
else _state.blobsState.set(blobUrl, { state: 'error', message: blob.error });
+
};
+
+
const media = _state.attachedMedia;
+
if (media?.$type == 'app.bsky.embed.images') {
+
for (const image of media.images) {
+
const blobUrl = (image.image as AtpBlob<string>).ref.$link;
+
uploadImage(blobUrl).then((r) => handleUpload(blobUrl, r));
+
}
+
} else if (media?.$type === 'app.bsky.embed.video') {
+
const blobUrl = (media.video as AtpBlob<string>).ref.$link;
+
uploadVideo(blobUrl, media.video.mimeType).then((r) => handleUpload(blobUrl, r));
+
}
+
+
input.value = '';
+
};
+
+
const removeMedia = () => {
+
if (_state.attachedMedia?.$type === 'app.bsky.embed.video') {
+
const blobUrl = (_state.attachedMedia.video as AtpBlob<string>).ref.$link;
+
_state.blobsState.delete(blobUrl);
+
}
+
_state.attachedMedia = undefined;
+
};
+
+
const removeMediaAtIndex = (index: number) => {
+
if (_state.attachedMedia?.$type !== 'app.bsky.embed.images') return;
+
const imageToRemove = _state.attachedMedia.images[index];
+
const blobUrl = (imageToRemove.image as AtpBlob<string>).ref.$link;
+
_state.blobsState.delete(blobUrl);
+
+
const images = _state.attachedMedia.images.filter((_, i) => i !== index);
+
_state.attachedMedia = images.length > 0 ? { ..._state.attachedMedia, images } : undefined;
};
const doPost = () => {
+
if (_state.text.length === 0 || _state.text.length > 300) return;
+
postError = '';
+
posting = true;
+
post(_state.text)
+
.then((res) => {
+
if (res.ok) {
+
onPostSent(res.value);
+
_state.text = '';
+
_state.attachedMedia = undefined;
+
_state.blobsState.clear();
+
unfocus();
+
} else {
+
postError = res.error;
+
}
+
})
+
.finally(() => {
+
posting = false;
+
});
};
$effect(() => {
document.documentElement.style.setProperty('--acc-color', color);
if (isFocused && textareaEl) textareaEl.focus();
});
</script>
+
{#snippet attachedPost(post: PostWithUri, type: 'quoting' | 'replying')}
+
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
+
<BskyPost {client} did={parsedUri.repo} rkey={parsedUri.rkey} data={post} isOnPostComposer={true}>
+
{#snippet cornerFragment()}
+
<button
+
class="transition-transform hover:scale-150"
+
onclick={() => {
+
_state[type] = undefined;
+
}}><Icon width={24} icon="heroicons:x-mark-16-solid" /></button
+
>
+
{/snippet}
+
</BskyPost>
+
{/snippet}
+
+
{#snippet attachmentIndicator(post: PostWithUri, type: 'quoting' | 'replying')}
{@const parsedUri = expect(parseCanonicalResourceUri(post.uri))}
+
{@const color = generateColorForDid(parsedUri.repo)}
+
{@const id = handles.get(parsedUri.repo) ?? parsedUri.repo}
+
<div
+
class="flex shrink-0 items-center gap-1.5 rounded-sm border py-0.5 pr-0.5 pl-1 text-xs font-bold transition-all"
+
style="
+
background: color-mix(in srgb, {color} 10%, transparent);
+
border-color: {color};
+
color: {color};
+
"
+
title={type === 'replying' ? `replying to ${id}` : `quoting ${id}`}
+
>
+
<span class="truncate text-sm font-normal opacity-90">
+
{type === 'replying' ? 'replying to' : 'quoting'}
+
</span>
+
<div class="shrink-0">
+
<ProfilePicture {client} did={parsedUri.repo} size={5} />
+
</div>
+
</div>
+
{/snippet}
+
+
{#snippet highlighter(text: string)}
+
{#each tokenize(text) as token, idx (idx)}
+
{@const highlighted =
+
token.type === 'mention' ||
+
token.type === 'topic' ||
+
token.type === 'link' ||
+
token.type === 'autolink'}
+
<span class={highlighted ? 'text-(--nucleus-accent2)' : ''}>{token.raw}</span>
+
{/each}
+
{#if text.endsWith('\n')}
+
<br />
+
{/if}
+
{/snippet}
+
+
{#snippet uploadControls(blobUrl: string, remove: () => void)}
+
{@const upload = _state.blobsState.get(blobUrl)}
+
{#if upload !== undefined && upload.state === 'uploading'}
+
<div
+
class="absolute top-2 right-2 z-10 flex items-center gap-2 rounded-sm bg-black/70 p-1.5 text-sm backdrop-blur-sm"
+
>
+
<div class="flex justify-center">
+
<div
+
class="h-5 w-5 animate-spin rounded-full border-4 border-t-transparent"
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
+
></div>
+
</div>
+
<span class="font-medium">{Math.round(upload.progress * 100)}%</span>
+
</div>
+
{:else}
+
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
+
{#if upload !== undefined && upload.state === 'error'}
+
<span
+
class="rounded-sm bg-black/70 p-1.5 px-1 text-sm font-bold text-red-500 backdrop-blur-sm"
+
>{upload.message}</span
+
>
+
{/if}
+
<button
+
onclick={(e) => {
+
e.preventDefault();
+
e.stopPropagation();
+
remove();
+
}}
+
onmousedown={(e) => e.preventDefault()}
+
class="rounded-sm bg-black/70 p-1.5 backdrop-blur-sm {upload?.state !== 'error'
+
? 'opacity-0 transition-opacity group-hover:opacity-100'
+
: ''}"
+
>
+
{#if upload?.state === 'error'}
+
<Icon
+
class="text-red-500 group-hover:hidden"
+
icon="heroicons:exclamation-circle-16-solid"
+
width={20}
+
/>
+
{/if}
+
<Icon
+
class={upload?.state === 'error' ? 'hidden group-hover:block' : ''}
+
icon="heroicons:x-mark-16-solid"
+
width={20}
+
/>
+
</button>
+
</div>
+
{/if}
{/snippet}
+
{#snippet mediaPreview(embed: AppBskyEmbedMedia)}
+
{#if embed.$type === 'app.bsky.embed.images'}
+
<div class="image-preview-grid" data-total={embed.images.length}>
+
{#each embed.images as image, idx (idx)}
+
{@const blobUrl = (image.image as AtpBlob<string>).ref.$link}
+
<div class="image-preview-item group">
+
<img src={blobUrl} alt="" />
+
{@render uploadControls(blobUrl, () => removeMediaAtIndex(idx))}
+
</div>
+
{/each}
+
</div>
+
{:else if embed.$type === 'app.bsky.embed.video'}
+
{@const blobUrl = (embed.video as AtpBlob<string>).ref.$link}
+
<div
+
class="group relative max-h-[30vh] overflow-hidden rounded-sm"
+
style="aspect-ratio: 16/10;"
+
>
+
<!-- svelte-ignore a11y_media_has_caption -->
+
<video src={blobUrl} controls class="h-full w-full"></video>
+
{@render uploadControls(blobUrl, removeMedia)}
+
</div>
+
{/if}
+
{/snippet}
+
+
{#snippet composer(replying?: PostWithUri, quoting?: PostWithUri)}
+
{@const hasIncompleteUpload = _state.blobsState
+
.values()
+
.some((s) => s.state === 'uploading' || s.state === 'error')}
<div class="flex items-center gap-2">
+
<input
+
bind:this={fileInputEl}
+
type="file"
+
accept="image/*,video/*"
+
multiple
+
onchange={handleFileSelect}
+
oncancel={() => (selectingFile = false)}
+
class="hidden"
+
/>
+
<button
+
onclick={(e) => {
+
e.preventDefault();
+
e.stopPropagation();
+
selectingFile = true;
+
fileInputEl?.click();
+
}}
+
onmousedown={(e) => e.preventDefault()}
+
disabled={_state.attachedMedia?.$type === 'app.bsky.embed.video' ||
+
(_state.attachedMedia?.$type === 'app.bsky.embed.images' &&
+
_state.attachedMedia.images.length >= 4)}
+
class="rounded-sm p-1.5 transition-all duration-150 enabled:hover:scale-110 disabled:cursor-not-allowed disabled:opacity-50"
+
style="background: color-mix(in srgb, {color} 15%, transparent); color: {color};"
+
title="attach media"
+
>
+
<Icon icon="heroicons:photo-16-solid" width={20} />
+
</button>
+
{#if postError.length > 0}
+
<div class="group flex items-center gap-2 truncate rounded-sm bg-red-500 p-1.5">
+
<button onclick={() => (postError = '')}>
+
<Icon
+
class="group-hover:hidden"
+
icon="heroicons:exclamation-circle-16-solid"
+
width={20}
+
/>
+
<Icon class="hidden group-hover:block" icon="heroicons:x-mark-16-solid" width={20} />
+
</button>
+
<span title={postError} class="truncate text-sm font-bold">{postError}</span>
+
</div>
+
{/if}
<div class="grow"></div>
+
{#if posting}
+
<div
+
class="h-6 w-6 animate-spin rounded-full border-4 border-t-transparent"
+
style="border-color: var(--nucleus-accent) var(--nucleus-accent) var(--nucleus-accent) transparent;"
+
></div>
+
{/if}
<span
+
class="text-sm font-medium text-nowrap"
+
style="color: color-mix(in srgb, {_state.text.length > 300
? '#ef4444'
: 'var(--nucleus-fg)'} 53%, transparent);"
>
+
{_state.text.length} / 300
</span>
<button
+
onmousedown={(e) => e.preventDefault()}
+
onclick={doPost}
+
disabled={(!_state.attachedMedia && _state.text.length === 0) ||
+
_state.text.length > 300 ||
+
hasIncompleteUpload}
+
class="action-button border-none px-4 py-1.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 attachedPost(replying, 'replying')}
{/if}
<div class="composer space-y-2">
+
<div class="relative grid">
+
<!-- todo: replace this with a proper rich text editor -->
+
<div
+
class="pointer-events-none col-start-1 row-start-1 min-h-[5lh] w-full bg-transparent text-wrap break-all whitespace-pre-wrap text-(--nucleus-fg)"
+
aria-hidden="true"
+
>
+
{@render highlighter(_state.text)}
+
</div>
+
+
<textarea
+
bind:this={textareaEl}
+
bind:value={_state.text}
+
onfocus={() => (_state.focus = 'focused')}
+
onblur={() => (!selectingFile ? unfocus() : null)}
+
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="col-start-1 row-start-1 field-sizing-content min-h-[5lh] w-full resize-none overflow-hidden bg-transparent text-wrap break-all whitespace-pre-wrap text-transparent caret-(--nucleus-fg) placeholder:text-(--nucleus-fg)/45"
+
></textarea>
+
</div>
+
{#if _state.attachedMedia}
+
{@render mediaPreview(_state.attachedMedia)}
+
{/if}
{#if quoting}
+
{@render attachedPost(quoting, 'quoting')}
{/if}
</div>
{/snippet}
···
<!-- 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' : ''}
···
: `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 px-2">
+
{#if !client.atcute}
<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};"
>
+
not logged in
</div>
{:else}
+
<div class="flex flex-col gap-1">
+
{#if _state.focus === 'focused'}
+
{@render composer(_state.replying, _state.quoting)}
{:else}
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
+
<div
+
class="composer relative flex cursor-text items-center gap-0 py-0! transition-all hover:brightness-110"
+
onmousedown={(e) => {
+
if (e.defaultPrevented) return;
+
_state.focus = 'focused';
+
}}
+
>
+
{#if _state.replying}
+
{@render attachmentIndicator(_state.replying, 'replying')}
+
{/if}
+
<input
+
bind:value={_state.text}
+
onfocus={() => (_state.focus = 'focused')}
+
type="text"
+
placeholder="what's on your mind?"
+
class="min-w-0 flex-1 border-none bg-transparent outline-none placeholder:text-(--nucleus-fg)/45 focus:ring-0"
+
/>
+
{#if _state.quoting}
+
{@render attachmentIndicator(_state.quoting, 'quoting')}
+
{/if}
+
</div>
{/if}
</div>
{/if}
···
</div>
</div>
<style>
@reference "../app.css";
···
}
.composer {
+
@apply p-1;
}
textarea {
+
@apply w-full p-0;
}
input {
+
@apply p-1.5;
}
.composer {
@apply focus:scale-100;
}
+
input::placeholder {
color: color-mix(in srgb, var(--acc-color) 45%, var(--nucleus-bg));
}
textarea:focus {
@apply border-none! [box-shadow:none]! outline-none!;
+
}
+
+
/* Image preview grid - based on PhotoSwipeGallery */
+
.image-preview-grid {
+
display: grid;
+
gap: 2px;
+
border-radius: 4px;
+
overflow: hidden;
+
width: 100%;
+
max-height: 30vh;
+
}
+
+
.image-preview-item {
+
width: 100%;
+
height: 100%;
+
display: block;
+
position: relative;
+
overflow: hidden;
+
border-radius: 4px;
+
}
+
+
.image-preview-item > img {
+
width: 100%;
+
height: 100%;
+
object-fit: cover;
+
}
+
+
/* Single image: natural aspect ratio */
+
.image-preview-grid[data-total='1'] {
+
display: block;
+
height: auto;
+
width: 100%;
+
border-radius: 0;
+
}
+
+
.image-preview-grid[data-total='1'] .image-preview-item {
+
width: 100%;
+
height: auto;
+
display: block;
+
border-radius: 4px;
+
}
+
+
.image-preview-grid[data-total='1'] .image-preview-item > img {
+
width: 100%;
+
height: auto;
+
max-height: 60vh;
+
object-fit: contain;
+
}
+
+
/* 2 Images: Split vertically */
+
.image-preview-grid[data-total='2'] {
+
grid-template-columns: 1fr 1fr;
+
grid-template-rows: 1fr;
+
aspect-ratio: 16/9;
+
}
+
+
/* 3 Images: 1 Big (left), 2 Small (stacked right) */
+
.image-preview-grid[data-total='3'] {
+
grid-template-columns: 1fr 1fr;
+
grid-template-rows: 1fr 1fr;
+
aspect-ratio: 16/9;
+
}
+
.image-preview-grid[data-total='3'] .image-preview-item:first-child {
+
grid-row: span 2;
+
}
+
+
/* 4 Images: 2x2 Grid */
+
.image-preview-grid[data-total='4'] {
+
grid-template-columns: 1fr 1fr;
+
grid-template-rows: 1fr 1fr;
+
aspect-ratio: 16/9;
}
</style>
+96
src/components/ProfileInfo.svelte
···
···
+
<script lang="ts">
+
import { AtpClient, resolveDidDoc } from '$lib/at/client';
+
import type { Did, Handle } from '@atcute/lexicons/syntax';
+
import type { AppBskyActorProfile } from '@atcute/bluesky';
+
import ProfilePicture from './ProfilePicture.svelte';
+
import RichText from './RichText.svelte';
+
import { onMount } from 'svelte';
+
import { handles, profiles } from '$lib/state.svelte';
+
+
interface Props {
+
client: AtpClient;
+
did: Did;
+
handle?: Handle;
+
profile?: AppBskyActorProfile.Main | null;
+
}
+
+
let {
+
client,
+
did,
+
handle = handles.get(did),
+
profile = $bindable(profiles.get(did) ?? null)
+
}: Props = $props();
+
+
onMount(async () => {
+
await Promise.all([
+
(async () => {
+
if (profile) return;
+
const res = await client.getProfile(did);
+
if (!res.ok) return;
+
profile = res.value;
+
profiles.set(did, res.value);
+
})(),
+
(async () => {
+
if (handle) return;
+
const res = await resolveDidDoc(did);
+
if (!res.ok) return;
+
handle = res.value.handle;
+
handles.set(did, res.value.handle);
+
})()
+
]);
+
});
+
+
let displayHandle = $derived(handle ?? 'handle.invalid');
+
let profileDesc = $derived(profile?.description?.trim() ?? '');
+
let profileDisplayName = $derived(profile?.displayName ?? '');
+
let showDid = $state(false);
+
</script>
+
+
<div class="flex flex-col gap-2">
+
<div class="flex items-center gap-2">
+
<ProfilePicture {client} {did} size={20} />
+
+
<div class="flex min-w-0 flex-col items-start overflow-hidden overflow-ellipsis">
+
<span class="mb-1.5 min-w-0 overflow-hidden text-2xl text-nowrap overflow-ellipsis">
+
{profileDisplayName.length > 0 ? profileDisplayName : displayHandle}
+
{#if profile?.pronouns}
+
<span class="shrink-0 text-sm text-nowrap opacity-60">({profile.pronouns})</span>
+
{/if}
+
</span>
+
<button
+
oncontextmenu={(e) => {
+
e.stopPropagation();
+
const node = e.target as Node;
+
const selection = window.getSelection() ?? new Selection();
+
const range = document.createRange();
+
range.selectNodeContents(node);
+
selection.removeAllRanges();
+
selection.addRange(range);
+
}}
+
onmousedown={(e) => {
+
// disable double clicks to disable "double click to select text"
+
// since it doesnt work with us toggling did vs handle
+
if (e.detail > 1) e.preventDefault();
+
}}
+
onclick={() => (showDid = !showDid)}
+
class="mb-0.5 text-nowrap opacity-85 select-text hover:underline"
+
>
+
{showDid ? did : `@${displayHandle}`}
+
</button>
+
{#if profile?.website}
+
<a
+
target="_blank"
+
rel="noopener noreferrer"
+
href={profile.website}
+
class="text-sm text-nowrap opacity-60 hover:underline">{profile.website}</a
+
>
+
{/if}
+
</div>
+
</div>
+
+
{#if profileDesc.length > 0}
+
<div class="rounded-sm bg-black/25 p-1.5 text-wrap wrap-break-word">
+
<RichText text={profileDesc} />
+
</div>
+
{/if}
+
</div>
+40 -22
src/components/ProfilePicture.svelte
···
import PfpPlaceholder from './PfpPlaceholder.svelte';
import { img } from '$lib/cdn';
import type { Did } from '@atcute/lexicons';
interface Props {
client: AtpClient;
···
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}
···
import PfpPlaceholder from './PfpPlaceholder.svelte';
import { img } from '$lib/cdn';
import type { Did } from '@atcute/lexicons';
+
import { profiles } from '$lib/state.svelte';
interface Props {
client: AtpClient;
···
let { client, did, size }: Props = $props();
+
// svelte-ignore state_referenced_locally
+
let avatarBlob = $state(profiles.get(did)?.avatar);
+
const avatarUrl: string | null = $derived(
+
isBlob(avatarBlob) ? img('avatar_thumbnail', did, avatarBlob.ref.$link) : null
+
);
+
+
const loadProfile = async (targetDid: Did) => {
+
const cachedBlob = profiles.get(did)?.avatar;
+
if (cachedBlob) {
+
avatarBlob = cachedBlob;
+
return;
+
}
+
+
try {
+
const profile = await client.getProfile(targetDid);
+
if (profile.ok) {
+
avatarBlob = profile.value.avatar;
+
profiles.set(did, profile.value);
+
} else avatarBlob = undefined;
+
} catch (e) {
+
console.error(`${targetDid}: failed to load pfp`, e);
+
avatarBlob = undefined;
+
}
+
};
+
+
$effect(() => {
+
loadProfile(did);
+
});
+
let color = $derived(generateColorForDid(did));
</script>
+
{#if avatarUrl}
+
<img
+
class="rounded-sm bg-(--nucleus-accent)/10"
+
loading="lazy"
+
style="width: calc(var(--spacing) * {size}); height: calc(var(--spacing) * {size});"
+
alt="avatar for {did}"
+
src={avatarUrl}
+
/>
+
{:else}
<PfpPlaceholder {color} {size} />
+
{/if}
+139
src/components/ProfileView.svelte
···
···
+
<script lang="ts">
+
import { AtpClient, resolveDidDoc, resolveHandle } from '$lib/at/client';
+
import {
+
isHandle,
+
type ActorIdentifier,
+
type AtprotoDid,
+
type Did,
+
type Handle
+
} from '@atcute/lexicons/syntax';
+
import TimelineView from './TimelineView.svelte';
+
import ProfileInfo from './ProfileInfo.svelte';
+
import type { State as PostComposerState } from './PostComposer.svelte';
+
import Icon from '@iconify/svelte';
+
import { generateColorForDid } from '$lib/accounts';
+
import { img } from '$lib/cdn';
+
import { isBlob } from '@atcute/lexicons/interfaces';
+
import type { AppBskyActorProfile } from '@atcute/bluesky';
+
import { onMount } from 'svelte';
+
import { handles, profiles } from '$lib/state.svelte';
+
+
interface Props {
+
client: AtpClient;
+
actor: string;
+
onBack: () => void;
+
postComposerState: PostComposerState;
+
}
+
+
let { client, actor, onBack, postComposerState = $bindable() }: Props = $props();
+
+
let profile = $state<AppBskyActorProfile.Main | null>(profiles.get(actor as Did) ?? null);
+
const displayName = $derived(profile?.displayName ?? '');
+
let loading = $state(true);
+
let error = $state<string | null>(null);
+
let did = $state<AtprotoDid | null>(null);
+
let handle = $state<Handle | null>(handles.get(actor as Did) ?? null);
+
+
const loadProfile = async (identifier: ActorIdentifier) => {
+
loading = true;
+
error = null;
+
profile = null;
+
handle = isHandle(identifier) ? identifier : null;
+
+
const resDid = await resolveHandle(identifier);
+
if (resDid.ok) did = resDid.value;
+
else {
+
error = resDid.error;
+
loading = false;
+
return;
+
}
+
+
if (!handle) handle = handles.get(did) ?? null;
+
+
if (!handle) {
+
const resHandle = await resolveDidDoc(did);
+
if (resHandle.ok) {
+
handle = resHandle.value.handle;
+
handles.set(did, resHandle.value.handle);
+
}
+
}
+
+
const res = await client.getProfile(did);
+
if (res.ok) {
+
profile = res.value;
+
profiles.set(did, res.value);
+
} else error = res.error;
+
+
loading = false;
+
};
+
+
onMount(async () => {
+
await loadProfile(actor as ActorIdentifier);
+
});
+
+
const color = $derived(did ? generateColorForDid(did) : 'var(--nucleus-fg)');
+
const bannerUrl = $derived(
+
did && profile && isBlob(profile.banner)
+
? img('feed_fullsize', did, profile.banner.ref.$link)
+
: null
+
);
+
</script>
+
+
<div class="flex min-h-dvh flex-col">
+
<!-- header -->
+
<div
+
class="sticky top-0 z-20 flex items-center gap-4 border-b-2 bg-(--nucleus-bg)/80 p-4 backdrop-blur-md"
+
style="border-color: {color}40;"
+
>
+
<button
+
onclick={onBack}
+
class="rounded-full p-1 text-(--nucleus-fg) transition-all hover:bg-(--nucleus-fg)/10"
+
>
+
<Icon icon="heroicons:arrow-left-20-solid" width={24} />
+
</button>
+
<h2 class="text-xl font-bold">
+
{displayName.length > 0
+
? displayName
+
: loading
+
? 'loading...'
+
: (handle ?? actor ?? 'profile')}
+
</h2>
+
</div>
+
+
{#if error}
+
<div class="p-8 text-center text-red-500">
+
<p>failed to load profile: {error}</p>
+
</div>
+
{:else}
+
<!-- banner -->
+
<div class="relative h-32 w-full overflow-hidden bg-(--nucleus-fg)/5 md:h-48">
+
{#if bannerUrl}
+
<img src={bannerUrl} alt="banner" class="h-full w-full object-cover" />
+
{/if}
+
<div
+
class="absolute inset-0 bg-linear-to-b from-transparent to-(--nucleus-bg)"
+
style="opacity: 0.8;"
+
></div>
+
</div>
+
+
<div class="px-4 pb-4">
+
<div class="relative z-10 -mt-12 mb-4">
+
{#if did}
+
<ProfileInfo {client} {did} bind:profile />
+
{/if}
+
</div>
+
+
<div class="my-4 h-px bg-white/10"></div>
+
+
{#if did}
+
<TimelineView
+
showReplies={false}
+
{client}
+
targetDid={did}
+
bind:postComposerState
+
class="min-h-[50vh]"
+
/>
+
{/if}
+
</div>
+
{/if}
+
</div>
+78
src/components/RichText.svelte
···
···
+
<script lang="ts">
+
import { parseToRichText } from '$lib/richtext';
+
import { settings } from '$lib/settings';
+
import { router } from '$lib/state.svelte';
+
import type { BakedRichtext } from '@atcute/bluesky-richtext-builder';
+
import { segmentize, type Facet, type RichtextSegment } from '@atcute/bluesky-richtext-segmenter';
+
+
interface Props {
+
text: string;
+
facets?: Facet[];
+
}
+
+
const { text, facets }: Props = $props();
+
+
const richtext: Promise<BakedRichtext> = $derived(
+
facets ? Promise.resolve({ text, facets }) : parseToRichText(text)
+
);
+
+
const handleProfileClick = (e: MouseEvent, did: string) => {
+
e.preventDefault();
+
router.navigate(`/profile/${did}`);
+
};
+
</script>
+
+
{#snippet plainText(text: string)}
+
{#each text.split(/(\s)/) as line, idx (idx)}
+
{#if line === '\n'}
+
<br />
+
{:else}
+
{line}
+
{/if}
+
{/each}
+
{/snippet}
+
+
{#snippet segments(segments: RichtextSegment[])}
+
{#each segments as segment, idx (idx)}
+
{@const { text, features: _features } = segment}
+
{@const features = _features ?? []}
+
{#if features.length > 0}
+
{#each features as feature, idx (idx)}
+
{#if feature.$type === 'app.bsky.richtext.facet#mention'}
+
<a
+
class="text-(--nucleus-accent2) hover:cursor-pointer hover:underline"
+
href={`/profile/${feature.did}`}
+
onclick={(e) => handleProfileClick(e, feature.did)}>{@render plainText(text)}</a
+
>
+
{:else if feature.$type === 'app.bsky.richtext.facet#link'}
+
{@const uri = new URL(feature.uri)}
+
{@const text = `${!uri.protocol.startsWith('http') ? `${uri.protocol}//` : ''}${uri.host}${uri.hash.length === 0 && uri.search.length === 0 && uri.pathname === '/' ? '' : uri.pathname}${uri.search}${uri.hash}`}
+
<a
+
class="text-(--nucleus-accent2)"
+
href={uri.href}
+
target="_blank"
+
rel="noopener noreferrer"
+
>{@render plainText(`${text.substring(0, 40)}${text.length > 40 ? '...' : ''}`)}</a
+
>
+
{:else if feature.$type === 'app.bsky.richtext.facet#tag'}
+
<a
+
class="text-(--nucleus-accent2)"
+
href={`${$settings.socialAppUrl}/search?q=${encodeURIComponent('#' + feature.tag)}`}
+
target="_blank"
+
rel="noopener noreferrer">{@render plainText(text)}</a
+
>
+
{:else}
+
<span>{@render plainText(text)}</span>
+
{/if}
+
{/each}
+
{:else}
+
<span>{@render plainText(text)}</span>
+
{/if}
+
{/each}
+
{/snippet}
+
+
{#await richtext}
+
{@render plainText(text)}
+
{:then richtext}
+
{@render segments(segmentize(richtext.text, richtext.facets))}
+
{/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>
···
+184
src/components/SettingsView.svelte
···
···
+
<script lang="ts">
+
import { defaultSettings, needsReload, settings } from '$lib/settings';
+
import { get } from 'svelte/store';
+
import ColorPicker from 'svelte-awesome-color-picker';
+
import Tabs from './Tabs.svelte';
+
import { portal } from 'svelte-portal';
+
import { cache } from '$lib/cache';
+
import { router } from '$lib/state.svelte';
+
+
interface Props {
+
tab: string;
+
}
+
+
let { tab }: Props = $props();
+
+
let localSettings = $state(get(settings));
+
let hasReloadChanges = $derived(needsReload($settings, localSettings));
+
+
$effect(() => {
+
$settings.theme = localSettings.theme;
+
});
+
+
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 = () => {
+
cache.clear();
+
alert('cache cleared!');
+
};
+
+
const onTabChange = (tab: string) => router.replace(`/settings/${tab}`);
+
</script>
+
+
{#snippet advancedTab()}
+
<div class="space-y-3 p-4">
+
<div>
+
<h3 class="header">api endpoints</h3>
+
<div class="borders space-y-4">
+
{#snippet _input(name: string, desc: string)}
+
<div>
+
<label for={name} class="header-desc block">
+
{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)')}
+
{@render _input('jetstream', 'jetstream url (for real-time updates)')}
+
</div>
+
</div>
+
+
<div class="borders">
+
<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>
+
+
<h3 class="header">cache management</h3>
+
<div class="borders">
+
<p class="header-desc">clears cached data (records, DID documents, handles, etc.)</p>
+
<button onclick={handleClearCache} class="action-button"> clear cache </button>
+
</div>
+
+
<h3 class="header">reset settings</h3>
+
<div class="borders">
+
<p class="header-desc">resets all settings to their default values</p>
+
<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 p-4">
+
<div>
+
<h3 class="header">colors</h3>
+
<div class="borders">
+
{#snippet color(name: string, desc: string)}
+
<div>
+
<label for={name} class="header-desc block">
+
{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}
+
+
<div class="flex flex-col">
+
<div class="mb-6 flex items-center justify-between p-4 pb-0">
+
<div>
+
<h2 class="text-3xl font-bold">settings</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 hasReloadChanges}
+
<button onclick={handleSave} class="action-button animate-pulse shadow-lg">
+
save & reload
+
</button>
+
{/if}
+
</div>
+
+
<div class="flex-1">
+
{#if tab === 'advanced'}
+
{@render advancedTab()}
+
{:else if tab === 'moderation'}
+
<div class="p-4">
+
<div class="flex h-64 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>
+
</div>
+
{:else if tab === 'style'}
+
{@render styleTab()}
+
{/if}
+
</div>
+
+
<div
+
use:portal={'#footer-portal'}
+
class="
+
z-20 w-full max-w-2xl bg-(--nucleus-bg) p-4 pt-2 pb-1 shadow-[0_-10px_20px_-5px_rgba(0,0,0,0.1)]
+
"
+
>
+
<Tabs tabs={['style', 'moderation', 'advanced']} activeTab={tab} {onTabChange} />
+
</div>
+
</div>
+
+
<style>
+
@reference "../app.css";
+
.borders {
+
@apply rounded-sm border-2 border-dashed border-(--nucleus-fg)/10 p-4;
+
}
+
.header-desc {
+
@apply mb-2 text-sm text-(--nucleus-fg)/80;
+
}
+
.header {
+
@apply mb-2 text-lg font-bold;
+
}
+
</style>
+5 -3
src/components/Tabs.svelte
···
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}
···
let { tabs, activeTab = $bindable(), onTabChange }: Props = $props();
</script>
+
<div class="flex rounded border-x-3 border-b-3 border-(--nucleus-accent)/20">
{#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
+
? 'rounded-t 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}
+209
src/components/TimelineView.svelte
···
···
+
<script lang="ts">
+
import BskyPost from './BskyPost.svelte';
+
import { type State as PostComposerState } from './PostComposer.svelte';
+
import { AtpClient } from '$lib/at/client';
+
import { accounts } from '$lib/accounts';
+
import { type ResourceUri } from '@atcute/lexicons';
+
import { SvelteSet } from 'svelte/reactivity';
+
import { InfiniteLoader, LoaderState } from 'svelte-infinite';
+
import {
+
postCursors,
+
fetchTimeline,
+
allPosts,
+
timelines,
+
fetchInteractionsUntil
+
} from '$lib/state.svelte';
+
import Icon from '@iconify/svelte';
+
import { buildThreads, filterThreads, type ThreadPost } from '$lib/thread';
+
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import NotLoggedIn from './NotLoggedIn.svelte';
+
+
interface Props {
+
client?: AtpClient | null;
+
targetDid?: AtprotoDid;
+
postComposerState: PostComposerState;
+
class?: string;
+
// whether to show replies that are not the user's own posts
+
showReplies?: boolean;
+
}
+
+
let {
+
client = null,
+
targetDid = undefined,
+
showReplies = true,
+
postComposerState = $bindable(),
+
class: className = ''
+
}: Props = $props();
+
+
let reverseChronological = $state(true);
+
let viewOwnPosts = $state(true);
+
const expandedThreads = new SvelteSet<ResourceUri>();
+
+
const did = $derived(targetDid ?? client?.user?.did);
+
+
const threads = $derived(
+
// todo: apply showReplies here
+
filterThreads(
+
did && timelines.has(did) ? buildThreads(did, timelines.get(did)!, allPosts) : [],
+
$accounts,
+
{ viewOwnPosts }
+
)
+
);
+
+
const loaderState = new LoaderState();
+
let scrollContainer = $state<HTMLDivElement>();
+
let loading = $state(false);
+
let fetchMoreInteractions: boolean | undefined = $state(false);
+
let loadError = $state('');
+
+
const loadMore = async () => {
+
if (loading || !client || !did) return;
+
+
loading = true;
+
loaderState.status = 'LOADING';
+
+
try {
+
await fetchTimeline(did as AtprotoDid, 7, showReplies);
+
// interaction fetching is done lazily so we dont block loading posts
+
fetchMoreInteractions = true;
+
loaderState.loaded();
+
} catch (error) {
+
loadError = `${error}`;
+
loaderState.error();
+
loading = false;
+
return;
+
}
+
+
loading = false;
+
const cursor = postCursors.get(did as AtprotoDid);
+
if (cursor && cursor.end) loaderState.complete();
+
};
+
+
$effect(() => {
+
if (threads.length === 0 && !loading && did) {
+
// if we saw all posts dont try to load more.
+
// this only really happens if the user has no posts at all
+
// but we do have to handle it to not cause an infinite loop
+
const cursor = did ? postCursors.get(did as AtprotoDid) : undefined;
+
if (!cursor?.end) loadMore();
+
}
+
if (client && did && fetchMoreInteractions) {
+
// set to false so it doesnt attempt to fetch again while its already fetching
+
fetchMoreInteractions = false;
+
fetchInteractionsUntil(client, did).then(() => (fetchMoreInteractions = undefined));
+
}
+
});
+
</script>
+
+
{#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={client!} {...post} />
+
</span>
+
{/snippet}
+
+
{#snippet threadsView()}
+
{#each threads as thread, i (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={client!}
+
onQuote={(post) => {
+
postComposerState.focus = 'focused';
+
postComposerState.quoting = post;
+
}}
+
onReply={(post) => {
+
postComposerState.focus = 'focused';
+
postComposerState.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>
+
{#if i < threads.length - 1}
+
<div
+
class="mx-8 mt-3 mb-4 h-px bg-linear-to-r from-(--nucleus-accent)/30 to-(--nucleus-accent2)/30"
+
></div>
+
{/if}
+
{/each}
+
{/snippet}
+
+
<div
+
class="min-h-full p-2 [scrollbar-color:var(--nucleus-accent)_transparent] {className}"
+
bind:this={scrollContainer}
+
>
+
{#if targetDid || $accounts.length > 0}
+
<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>
+
{:else}
+
<NotLoggedIn />
+
{/if}
+
</div>
+324 -131
src/lib/at/client.ts
···
-
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 {
isDid,
parseCanonicalResourceUri,
···
import * as v from '@atcute/lexicons/validations';
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;
-
export const handleCache = new PersistedLRU<Handle, AtprotoDid>({
-
max: 1000,
-
ttl: cacheTtl,
-
prefix: 'handle'
-
});
-
export const didDocCache = new PersistedLRU<ActorIdentifier, MiniDoc>({
-
max: 1000,
-
ttl: cacheTtl,
-
prefix: 'didDoc'
-
});
-
export const recordCache = new PersistedLRU<
-
string,
-
InferOutput<typeof ComAtprotoRepoGetRecord.mainSchema.output.schema>
-
>({
-
max: 5000,
-
ttl: cacheTtl,
-
prefix: 'record'
});
-
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);
-
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
-
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
-
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
-
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 {
···
if (!res.ok) throw res.data.error;
this.user = {
did: res.data.did,
-
handle: res.data.handle
};
this.atcute = rpc;
} catch (error) {
···
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({ 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);
-
-
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;
-
-
return ok(result.value);
}
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
···
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(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 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(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 Promise.race([
-
fetchMicrocosm(slingshotUrl, MiniDocQuery, {
-
identifier: handleOrDid
-
}),
-
cachedSignal.then((d): Result<MiniDoc, string> => ok(d))
-
]);
-
if (result.ok) didDocCache.set(handleOrDid, result.value);
-
-
return result;
}
async getBacklinksUri(
···
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]);
···
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,
···
+
import { err, expect, map, ok, type OkType, type Result } from '$lib/result';
import {
ComAtprotoIdentityResolveHandle,
ComAtprotoRepoGetRecord,
ComAtprotoRepoListRecords
} from '@atcute/atproto';
+
import { Client as AtcuteClient, simpleFetchHandler } from '@atcute/client';
+
import { safeParse, type Blob as AtpBlob, type Handle, type InferOutput } from '@atcute/lexicons';
import {
isDid,
parseCanonicalResourceUri,
···
import * as v from '@atcute/lexicons/validations';
import { MiniDocQuery, type MiniDoc } from './slingshot';
import { BacklinksQuery, type Backlinks, type BacklinksSource } from './constellation';
+
import type { Records, XRPCProcedures } from '@atcute/lexicons/ambient';
+
import { cache as rawCache } from '$lib/cache';
import { AppBskyActorProfile } from '@atcute/bluesky';
import { WebSocket } from '@soffinal/websocket';
import type { Notification } from './stardust';
import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
+
import { timestampFromCursor, toCanonicalUri, toResourceUri } from '$lib';
+
import { constellationUrl, slingshotUrl, spacedustUrl } from '.';
+
+
export type RecordOutput<Output> = { uri: ResourceUri; cid: Cid | undefined; record: Output };
+
+
const cacheWithHandles = rawCache.define(
+
'resolveHandle',
+
async (handle: Handle): Promise<AtprotoDid> => {
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoIdentityResolveHandle.mainSchema, {
+
handle
+
});
+
if (!res.ok) throw new Error(res.error);
+
return res.value.did as AtprotoDid;
+
}
+
);
+
+
const cacheWithDidDocs = cacheWithHandles.define(
+
'resolveDidDoc',
+
async (identifier: ActorIdentifier): Promise<MiniDoc> => {
+
const res = await fetchMicrocosm(slingshotUrl, MiniDocQuery, {
+
identifier
+
});
+
if (!res.ok) throw new Error(res.error);
+
return res.value;
+
}
+
);
+
const cacheWithRecords = cacheWithDidDocs.define('fetchRecord', async (uri: ResourceUri) => {
+
const parsedUri = parseResourceUri(uri);
+
if (!parsedUri.ok) throw new Error(`can't parse resource uri: ${parsedUri.error}`);
+
const { repo, collection, rkey } = parsedUri.value;
+
const res = await fetchMicrocosm(slingshotUrl, ComAtprotoRepoGetRecord.mainSchema, {
+
repo,
+
collection: collection!,
+
rkey: rkey!
+
});
+
if (!res.ok) throw new Error(res.error);
+
return res.value;
});
+
const cache = cacheWithRecords;
+
+
export const xhrPost = (
+
url: string,
+
body: Blob | File,
+
headers: Record<string, string> = {},
+
onProgress?: (uploaded: number, total: number) => void
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
): Promise<Result<any, { error: string; message: string }>> => {
+
return new Promise((resolve) => {
+
const xhr = new XMLHttpRequest();
+
xhr.open('POST', url);
+
+
if (onProgress && xhr.upload) {
+
xhr.upload.onprogress = (event: ProgressEvent) => {
+
if (event.lengthComputable) {
+
onProgress(event.loaded, event.total);
+
}
+
};
+
}
+
Object.keys(headers).forEach((key) => {
+
xhr.setRequestHeader(key, headers[key]);
+
});
+
xhr.onload = () => {
+
if (xhr.status >= 200 && xhr.status < 300) {
+
resolve(ok(JSON.parse(xhr.responseText)));
+
} else {
+
resolve(err(JSON.parse(xhr.responseText)));
+
}
+
};
+
+
xhr.onerror = () => {
+
resolve(err({ error: 'xhr_error', message: 'network error' }));
+
};
+
+
xhr.onabort = () => {
+
resolve(err({ error: 'xhr_error', message: 'upload aborted' }));
+
};
+
+
xhr.send(body);
+
});
+
};
+
+
export type UploadStatus =
+
| { stage: 'auth' }
+
| { stage: 'uploading'; progress?: number }
+
| { stage: 'processing'; progress?: number }
+
| { stage: 'complete' };
export class AtpClient {
public atcute: AtcuteClient | null = null;
+
public user: MiniDoc | null = null;
async login(agent: OAuthUserAgent): Promise<Result<null, string>> {
try {
···
if (!res.ok) throw res.data.error;
this.user = {
did: res.data.did,
+
handle: res.data.handle,
+
pds: agent.session.info.aud as `${string}:${string}`,
+
signing_key: ''
};
this.atcute = rpc;
} catch (error) {
···
rkey: RecordKey
): Promise<Result<RecordOutput<Output>, string>> {
const collection = schema.object.shape.$type.expected;
+
try {
+
const rawValue = await cache.fetchRecord(
+
toResourceUri({ repo, collection, rkey, fragment: undefined })
+
);
+
const parsed = safeParse(schema, rawValue.value);
+
if (!parsed.ok) return err(parsed.message);
+
return ok({
+
uri: rawValue.uri,
+
cid: rawValue.cid,
+
record: parsed.value as Output
+
});
+
} catch (e) {
+
return err(String(e));
+
}
}
async getProfile(repo?: ActorIdentifier): Promise<Result<AppBskyActorProfile.Main, string>> {
···
async listRecords<Collection extends keyof Records>(
collection: Collection,
cursor?: string,
+
limit: number = 100
): Promise<
Result<InferXRPCBodyOutput<(typeof ComAtprotoRepoListRecords.mainSchema)['output']>, string>
> {
+
if (!this.atcute || !this.user) return err('not authenticated');
const res = await this.atcute.get('com.atproto.repo.listRecords', {
params: {
+
repo: this.user.did,
collection,
cursor,
+
limit,
+
reverse: false
}
});
if (!res.ok) return err(`${res.data.error}: ${res.data.message ?? 'no details'}`);
+
for (const record of res.data.records)
+
await cache.set('fetchRecord', `fetchRecord~${record.uri}`, record, 60 * 60 * 24);
+
return ok(res.data);
}
+
async listRecordsUntil<Collection extends keyof Records>(
+
collection: Collection,
+
cursor?: string,
+
timestamp: number = -1
+
): Promise<ReturnType<typeof this.listRecords>> {
+
const data: OkType<Awaited<ReturnType<typeof this.listRecords>>> = {
+
records: [],
+
cursor
+
};
+
let end = false;
+
while (!end) {
+
const res = await this.listRecords(collection, data.cursor);
+
if (!res.ok) return res;
+
data.cursor = res.value.cursor;
+
data.records.push(...res.value.records);
+
end = data.records.length === 0 || !data.cursor;
+
if (!end && timestamp > 0) {
+
const cursorTimestamp = timestampFromCursor(data.cursor);
+
if (cursorTimestamp === undefined) {
+
console.warn(
+
'could not parse timestamp from cursor, stopping fetch to prevent infinite loop:',
+
data.cursor
+
);
+
end = true;
+
} else if (cursorTimestamp <= timestamp) {
+
end = true;
+
} else {
+
console.info(
+
`${this.user?.did}: continuing to fetch ${collection}, on ${cursorTimestamp} until ${timestamp}`
+
);
+
}
+
}
+
}
+
return ok(data);
}
async getBacklinksUri(
···
repo: ActorIdentifier,
collection: Nsid,
rkey: RecordKey,
+
source: BacklinksSource,
+
limit?: number
): Promise<Result<Backlinks, string>> {
+
const did = await 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: toCanonicalUri({ did: did.value, collection, rkey }),
source,
+
limit: limit || 100
});
const results = await Promise.race([query, timeout]);
···
return results;
}
+
async getServiceAuth(lxm: keyof XRPCProcedures, exp: number): Promise<Result<string, string>> {
+
if (!this.atcute || !this.user) return err('not authenticated');
+
const serviceAuthUrl = new URL(`${this.user.pds}xrpc/com.atproto.server.getServiceAuth`);
+
serviceAuthUrl.searchParams.append(
+
'aud',
+
this.user.pds.replace('https://', 'did:web:').slice(0, -1)
+
);
+
serviceAuthUrl.searchParams.append('lxm', 'com.atproto.repo.uploadBlob');
+
serviceAuthUrl.searchParams.append('exp', exp.toString()); // 30 minutes
+
+
const serviceAuthResponse = await this.atcute.handler(
+
`${serviceAuthUrl.pathname}${serviceAuthUrl.search}`,
+
{
+
method: 'GET'
+
}
+
);
+
if (!serviceAuthResponse.ok) {
+
const error = await serviceAuthResponse.text();
+
return err(`failed to get service auth: ${error}`);
+
}
+
const serviceAuth = await serviceAuthResponse.json();
+
return ok(serviceAuth.token);
}
+
async uploadBlob(
+
blob: Blob,
+
onProgress?: (progress: number) => void
+
): Promise<Result<AtpBlob<string>, string>> {
+
if (!this.atcute || !this.user) return err('not authenticated');
+
const tokenResult = await this.getServiceAuth(
+
'com.atproto.repo.uploadBlob',
+
Math.floor(Date.now() / 1000) + 60
+
);
+
if (!tokenResult.ok) return tokenResult;
+
const result = await xhrPost(
+
`${this.user.pds}xrpc/com.atproto.repo.uploadBlob`,
+
blob,
+
{ authorization: `Bearer ${tokenResult.value}` },
+
(uploaded, total) => onProgress?.(uploaded / total)
+
);
+
if (!result.ok) return err(`upload failed: ${result.error.message}`);
+
return ok(result.value);
+
}
+
+
async uploadVideo(
+
blob: Blob,
+
mimeType: string,
+
onStatus?: (status: UploadStatus) => void
+
): Promise<Result<AtpBlob<string>, string>> {
+
if (!this.atcute || !this.user) return err('not authenticated');
+
+
onStatus?.({ stage: 'auth' });
+
const tokenResult = await this.getServiceAuth(
+
'com.atproto.repo.uploadBlob',
+
Math.floor(Date.now() / 1000) + 60 * 30
+
);
+
if (!tokenResult.ok) return tokenResult;
+
+
onStatus?.({ stage: 'uploading' });
+
const uploadUrl = new URL('https://video.bsky.app/xrpc/app.bsky.video.uploadVideo');
+
uploadUrl.searchParams.append('did', this.user.did);
+
uploadUrl.searchParams.append('name', 'video');
+
+
const uploadResult = await xhrPost(
+
uploadUrl.toString(),
+
blob,
+
{
+
Authorization: `Bearer ${tokenResult.value}`,
+
'Content-Type': mimeType
+
},
+
(uploaded, total) => onStatus?.({ stage: 'uploading', progress: uploaded / total })
+
);
+
if (!uploadResult.ok) return err(`failed to upload video: ${uploadResult.error.message}`);
+
const jobStatus = uploadResult.value;
+
let videoBlobRef: AtpBlob<string> = jobStatus.blob;
+
+
onStatus?.({ stage: 'processing' });
+
while (!videoBlobRef) {
+
await new Promise((resolve) => setTimeout(resolve, 1000));
+
+
const statusResponse = await fetch(
+
`https://video.bsky.app/xrpc/app.bsky.video.getJobStatus?jobId=${jobStatus.jobId}`
+
);
+
+
if (!statusResponse.ok) {
+
const error = await statusResponse.json();
+
// reuse blob
+
if (error.error === 'already_exists' && error.blob) {
+
videoBlobRef = error.blob;
+
break;
+
}
+
return err(`failed to get job status: ${error.message || error.error}`);
+
}
+
+
const status = await statusResponse.json();
+
if (status.jobStatus.blob) {
+
videoBlobRef = status.jobStatus.blob;
+
} else if (status.jobStatus.state === 'JOB_STATE_FAILED') {
+
return err(`video processing failed: ${status.jobStatus.error || 'unknown error'}`);
+
} else if (status.jobStatus.progress !== undefined) {
+
onStatus?.({
+
stage: 'processing',
+
progress: status.jobStatus.progress / 100
+
});
+
}
+
}
+
+
onStatus?.({ stage: 'complete' });
+
return ok(videoBlobRef);
+
}
}
+
+
export const newPublicClient = async (ident: ActorIdentifier): Promise<AtpClient> => {
+
const atp = new AtpClient();
+
const didDoc = await resolveDidDoc(ident);
+
if (!didDoc.ok) {
+
console.error('failed to resolve did doc', didDoc.error);
+
return atp;
+
}
+
atp.atcute = new AtcuteClient({ handler: simpleFetchHandler({ service: didDoc.value.pds }) });
+
atp.user = didDoc.value;
+
return atp;
+
};
+
+
// Wrappers that use the cache
+
+
export const resolveHandle = async (
+
identifier: ActorIdentifier
+
): Promise<Result<AtprotoDid, string>> => {
+
if (isDid(identifier)) return ok(identifier as AtprotoDid);
+
+
try {
+
const did = await cache.resolveHandle(identifier);
+
return ok(did);
+
} catch (e) {
+
return err(String(e));
+
}
+
};
+
+
export const resolveDidDoc = async (ident: ActorIdentifier): Promise<Result<MiniDoc, string>> => {
+
try {
+
const doc = await cache.resolveDidDoc(ident);
+
return ok(doc);
+
} catch (e) {
+
return err(String(e));
+
}
+
};
+
+
type NotificationsStreamEncoder = WebSocket.Encoder<undefined, Notification>;
+
export type NotificationsStream = WebSocket<NotificationsStreamEncoder>;
+
export type NotificationsStreamEvent = WebSocket.Event<NotificationsStreamEncoder>;
+
+
export const 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;
+
};
const fetchMicrocosm = async <
Schema extends XRPCQueryMetadata,
+54 -30
src/lib/at/fetch.ts
···
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
};
})
);
···
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;
})
);
···
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;
···
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));
···
type Cid,
type ResourceUri
} from '@atcute/lexicons';
+
import { type AtpClient } from './client';
+
import { err, expect, ok, type Ok, type Result } from '$lib/result';
import type { Backlinks } from './constellation';
import { AppBskyFeedPost } from '@atcute/bluesky';
+
import type { Did, RecordKey } from '@atcute/lexicons/syntax';
+
import { replySource, toCanonicalUri } from '$lib';
export type PostWithUri = { uri: ResourceUri; cid: Cid | undefined; record: AppBskyFeedPost.Main };
export type PostWithBacklinks = PostWithUri & {
+
replies?: Backlinks;
};
+
export const fetchPosts = async (
client: AtpClient,
cursor?: string,
+
limit?: number,
+
withBacklinks: boolean = true
+
): Promise<Result<{ posts: PostWithBacklinks[]; cursor?: string }, string>> => {
+
const recordsList = await client.listRecords('app.bsky.feed.post', cursor, limit);
if (!recordsList.ok) return err(`can't retrieve posts: ${recordsList.error}`);
cursor = recordsList.value.cursor;
const records = recordsList.value.records;
+
if (!withBacklinks) {
+
return ok({
+
posts: records.map((r) => ({
+
uri: r.uri,
+
cid: r.cid,
+
record: r.value as AppBskyFeedPost.Main
+
})),
+
cursor
+
});
+
}
+
try {
const allBacklinks = await Promise.all(
records.map(async (r): Promise<PostWithBacklinks> => {
+
const result = await client.getBacklinksUri(r.uri, replySource);
+
if (!result.ok) throw `cant fetch replies: ${result.error}`;
+
const replies = result.value;
return {
uri: r.uri,
cid: r.cid,
record: r.value as AppBskyFeedPost.Main,
+
replies
};
})
);
···
export const hydratePosts = async (
client: AtpClient,
+
repo: Did,
+
data: PostWithBacklinks[],
+
cacheFn: (did: Did, rkey: RecordKey) => Ok<PostWithUri> | undefined
): 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];
+
if (post.replies) {
+
const replies = await Promise.all(
+
post.replies.records.map(async (r) => {
+
const reply =
+
cacheFn(r.did, r.rkey) ??
+
(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;
})
);
···
const fetchUpwardsChain = async (post: PostWithUri) => {
let parent = post.record.reply?.parent;
while (parent) {
+
const parentUri = parent.uri as CanonicalResourceUri;
// if we already have this parent, then we already fetched this chain / are fetching it
+
if (posts.has(parentUri)) return;
+
const parsedParentUri = expect(parseCanonicalResourceUri(parentUri));
+
const p =
+
cacheFn(parsedParentUri.repo, parsedParentUri.rkey) ??
+
(await client.getRecord(
+
AppBskyFeedPost.mainSchema,
+
parsedParentUri.repo,
+
parsedParentUri.rkey
+
));
if (p.ok) {
posts.set(p.value.uri, p.value);
parent = p.value.record.reply?.parent;
···
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(toCanonicalUri(reply))) continue;
+
const record =
+
cacheFn(reply.did, reply.rkey) ??
+
(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));
+6
src/lib/at/index.ts
···
···
+
import { settings } from '$lib/settings';
+
import { get } from 'svelte/store';
+
+
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);
+5 -5
src/lib/at/oauth.ts
···
import {
configureOAuth,
-
defaultIdentityResolver,
createAuthorizationUrl,
finalizeAuthorization,
OAuthUserAgent,
···
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({
···
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));
···
import {
configureOAuth,
createAuthorizationUrl,
finalizeAuthorization,
OAuthUserAgent,
···
import {
CompositeDidDocumentResolver,
+
LocalActorResolver,
PlcDidDocumentResolver,
WebDidDocumentResolver,
XrpcHandleResolver
} from '@atcute/identity-resolver';
import type { ActorIdentifier } from '@atcute/lexicons';
import { err, ok, type Result } from '$lib/result';
import type { AtprotoDid } from '@atcute/lexicons/syntax';
+
import { clientId, oauthMetadata, redirectUri } from '$lib/oauth';
+
import { slingshotUrl } from '.';
configureOAuth({
metadata: {
client_id: clientId,
redirect_uri: redirectUri
},
+
identityResolver: new LocalActorResolver({
handleResolver: new XrpcHandleResolver({ serviceUrl: slingshotUrl.href }),
didDocumentResolver: new CompositeDidDocumentResolver({
···
try {
const authUrl = await createAuthorizationUrl({
target: { type: 'account', identifier },
+
scope: oauthMetadata.scope
});
// recommended to wait for the browser to persist local storage before proceeding
await new Promise((resolve) => setTimeout(resolve, 200));
+5
src/lib/at/types.ts
···
| AppBskyEmbedRecord.Main
| AppBskyEmbedRecordWithMedia.Main
| AppBskyEmbedVideo.Main;
···
| AppBskyEmbedRecord.Main
| AppBskyEmbedRecordWithMedia.Main
| AppBskyEmbedVideo.Main;
+
+
export type AppBskyEmbedMedia =
+
| AppBskyEmbedImages.Main
+
| AppBskyEmbedVideo.Main
+
| AppBskyEmbedExternal.Main;
+198 -68
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);
}
}
···
+
import { createCache } from 'async-cache-dedupe';
+
+
const DB_NAME = 'nucleus-cache';
+
const STORE_NAME = 'keyvalue';
+
const DB_VERSION = 1;
+
+
type WriteOp =
+
| {
+
type: 'put';
+
key: string;
+
value: { value: unknown; expires: number };
+
resolve: () => void;
+
reject: (err: unknown) => void;
+
}
+
| { type: 'delete'; key: string; resolve: () => void; reject: (err: unknown) => void };
+
type ReadOp = {
+
key: string;
+
resolve: (val: unknown) => void;
+
reject: (err: unknown) => void;
+
};
+
+
class IDBStorage {
+
private dbPromise: Promise<IDBDatabase> | null = null;
+
+
private getBatch: ReadOp[] = [];
+
private writeBatch: WriteOp[] = [];
+
+
private getFlushScheduled = false;
+
private writeFlushScheduled = false;
+
+
constructor() {
+
if (typeof indexedDB === 'undefined') return;
+
+
this.dbPromise = new Promise((resolve, reject) => {
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+
request.onerror = () => {
+
console.error('IDB open error:', request.error);
+
reject(request.error);
+
};
+
+
request.onsuccess = () => resolve(request.result);
+
request.onupgradeneeded = (event) => {
+
const db = (event.target as IDBOpenDBRequest).result;
+
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME);
+
};
+
});
+
}
+
async get(key: string): Promise<unknown> {
+
// checking in-flight writes
+
for (let i = this.writeBatch.length - 1; i >= 0; i--) {
+
const op = this.writeBatch[i];
+
if (op.key === key) {
+
if (op.type === 'delete') return undefined;
+
if (op.type === 'put') {
+
// if expired we dont want it
+
if (op.value.expires < Date.now()) return undefined;
+
return op.value.value;
+
}
+
}
+
}
+
if (!this.dbPromise) return undefined;
+
return new Promise((resolve, reject) => {
+
this.getBatch.push({ key, resolve, reject });
+
this.scheduleGetFlush();
});
+
}
+
private scheduleGetFlush() {
+
if (this.getFlushScheduled) return;
+
this.getFlushScheduled = true;
+
queueMicrotask(() => this.flushGetBatch());
}
+
private async flushGetBatch() {
+
this.getFlushScheduled = false;
+
const batch = this.getBatch;
+
this.getBatch = [];
+
if (batch.length === 0) return;
+
+
try {
+
const db = await this.dbPromise;
+
if (!db) throw new Error('DB not available');
+
+
const transaction = db.transaction(STORE_NAME, 'readonly');
+
const store = transaction.objectStore(STORE_NAME);
+
+
batch.forEach(({ key, resolve }) => {
+
try {
+
const request = store.get(key);
+
request.onsuccess = () => {
+
const result = request.result;
+
if (!result) {
+
resolve(undefined);
+
return;
+
}
+
if (result.expires < Date.now()) {
+
// Fire-and-forget removal for expired items
+
this.remove(key).catch(() => {});
+
resolve(undefined);
+
return;
+
}
+
resolve(result.value);
+
};
+
request.onerror = () => resolve(undefined);
+
} catch {
+
resolve(undefined);
+
}
+
});
+
} catch (error) {
+
batch.forEach(({ reject }) => reject(error));
}
}
+
async set(key: string, value: unknown, ttl: number): Promise<void> {
+
if (!this.dbPromise) return;
+
+
const expires = Date.now() + ttl * 1000;
+
const storageValue = { value, expires };
+
+
return new Promise((resolve, reject) => {
+
this.writeBatch.push({ type: 'put', key, value: storageValue, resolve, reject });
+
this.scheduleWriteFlush();
+
});
}
+
+
async remove(key: string): Promise<void> {
+
if (!this.dbPromise) return;
+
+
return new Promise((resolve, reject) => {
+
this.writeBatch.push({ type: 'delete', key, resolve, reject });
+
this.scheduleWriteFlush();
});
}
+
+
private scheduleWriteFlush() {
+
if (this.writeFlushScheduled) return;
+
this.writeFlushScheduled = true;
+
queueMicrotask(() => this.flushWriteBatch());
+
}
+
+
private async flushWriteBatch() {
+
this.writeFlushScheduled = false;
+
const batch = this.writeBatch;
+
this.writeBatch = [];
+
+
if (batch.length === 0) return;
+
+
try {
+
const db = await this.dbPromise;
+
if (!db) throw new Error('DB not available');
+
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
+
const store = transaction.objectStore(STORE_NAME);
+
+
batch.forEach((op) => {
+
try {
+
let request: IDBRequest;
+
if (op.type === 'put') request = store.put(op.value, op.key);
+
else request = store.delete(op.key);
+
+
request.onsuccess = () => op.resolve();
+
request.onerror = () => op.reject(request.error);
+
} catch (err) {
+
op.reject(err);
+
}
+
});
+
} catch (error) {
+
batch.forEach(({ reject }) => reject(error));
}
}
+
+
async clear(): Promise<void> {
+
if (!this.dbPromise) return;
+
try {
+
const db = await this.dbPromise;
+
return new Promise<void>((resolve, reject) => {
+
const transaction = db.transaction(STORE_NAME, 'readwrite');
+
const store = transaction.objectStore(STORE_NAME);
+
const request = store.clear();
+
+
request.onerror = () => reject(request.error);
+
request.onsuccess = () => resolve();
+
});
+
} catch (e) {
+
console.error('IDB clear error', e);
+
}
}
+
+
async exists(key: string): Promise<boolean> {
+
return (await this.get(key)) !== undefined;
}
+
+
async invalidate(key: string): Promise<void> {
+
return this.remove(key);
}
+
// noops
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
async getTTL(key: string): Promise<void> {
+
return;
}
+
async refresh(): Promise<void> {
+
return;
}
}
+
+
export const cache = createCache({
+
storage: {
+
type: 'custom',
+
options: {
+
storage: new IDBStorage()
+
}
+
},
+
ttl: 60 * 60 * 24, // 24 hours
+
onError: (err) => console.error(err)
+
});
+17
src/lib/date.ts
···
···
+
export const getRelativeTime = (date: Date, now: Date = 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}mo`;
+
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';
+
};
+240
src/lib/following.ts
···
···
+
import { type ActorIdentifier, type Did, type ResourceUri } from '@atcute/lexicons';
+
import type { PostWithUri } from './at/fetch';
+
import type { BacklinksSource } from './at/constellation';
+
import { extractDidFromUri, repostSource } from '$lib';
+
import type { AppBskyGraphFollow } from '@atcute/bluesky';
+
+
export type Sort = 'recent' | 'active' | 'conversational';
+
+
export const sortFollowedUser = (
+
sort: Sort,
+
statsA: NonNullable<ReturnType<typeof calculateFollowedUserStats>>,
+
statsB: NonNullable<ReturnType<typeof calculateFollowedUserStats>>
+
) => {
+
if (sort === 'conversational') {
+
if (Math.abs(statsB.conversationalScore! - statsA.conversationalScore!) > 0.1)
+
return statsB.conversationalScore! - statsA.conversationalScore!;
+
} else {
+
if (sort === 'active')
+
if (Math.abs(statsB.activeScore! - statsA.activeScore!) > 0.0001)
+
return statsB.activeScore! - statsA.activeScore!;
+
}
+
return statsB.lastPostAt!.getTime() - statsA.lastPostAt!.getTime();
+
};
+
+
const userStatsCache = new Map<
+
Did,
+
{ timestamp: number; stats: ReturnType<typeof _calculateStats> }
+
>();
+
const STATS_CACHE_TTL = 60 * 1000;
+
+
export const calculateFollowedUserStats = (
+
sort: Sort,
+
did: Did,
+
posts: Map<Did, Map<ResourceUri, PostWithUri>>,
+
interactionScores: Map<ActorIdentifier, number> | null,
+
now: number
+
) => {
+
if (sort === 'active') {
+
const cached = userStatsCache.get(did);
+
if (cached && now - cached.timestamp < STATS_CACHE_TTL) {
+
const postsMap = posts.get(did);
+
if (postsMap && postsMap.size > 0) return { ...cached.stats, did };
+
}
+
}
+
+
const stats = _calculateStats(sort, did, posts, interactionScores, now);
+
+
if (stats && sort === 'active') userStatsCache.set(did, { timestamp: now, stats });
+
+
return stats;
+
};
+
+
const _calculateStats = (
+
sort: Sort,
+
did: Did,
+
posts: Map<Did, Map<ResourceUri, PostWithUri>>,
+
interactionScores: Map<ActorIdentifier, number> | null,
+
now: number
+
) => {
+
const postsMap = posts.get(did);
+
if (!postsMap || postsMap.size === 0) return null;
+
+
let lastPostAtTime = 0;
+
let activeScore = 0;
+
let recentPostCount = 0;
+
const quarterPosts = 6 * 60 * 60 * 1000;
+
const gravity = 2.0;
+
+
for (const post of postsMap.values()) {
+
const t = new Date(post.record.createdAt).getTime();
+
if (t > lastPostAtTime) lastPostAtTime = t;
+
const ageMs = Math.max(0, now - t);
+
if (ageMs < quarterPosts) recentPostCount++;
+
if (sort === 'active') {
+
const ageHours = ageMs / (1000 * 60 * 60);
+
activeScore += 1 / Math.pow(ageHours + 1, gravity);
+
}
+
}
+
+
let conversationalScore = 0;
+
if (sort === 'conversational' && interactionScores)
+
conversationalScore = interactionScores.get(did) || 0;
+
+
return {
+
did,
+
lastPostAt: new Date(lastPostAtTime),
+
activeScore,
+
conversationalScore,
+
recentPostCount
+
};
+
};
+
+
const quoteWeight = 4;
+
const replyWeight = 6;
+
const repostWeight = 2;
+
+
const oneDay = 24 * 60 * 60 * 1000;
+
const halfLifeMs = 3 * oneDay;
+
const decayLambda = 0.693 / halfLifeMs;
+
+
const rateBaseline = 1;
+
const ratePower = 0.5;
+
const windowSize = 7 * oneDay;
+
+
const rateCache = new Map<Did, { rate: number; calculatedAt: number; postCount: number }>();
+
+
const getPostRate = (did: Did, posts: Map<ResourceUri, PostWithUri>, now: number): number => {
+
const cached = rateCache.get(did);
+
if (cached && cached.postCount === posts.size && now - cached.calculatedAt < 5 * 60 * 1000)
+
return cached.rate;
+
+
let volume = 0;
+
let minTime = now;
+
let maxTime = 0;
+
let hasRecentPosts = false;
+
+
for (const [, post] of posts) {
+
const t = new Date(post.record.createdAt).getTime();
+
if (now - t < windowSize) {
+
volume += 1;
+
if (t < minTime) minTime = t;
+
if (t > maxTime) maxTime = t;
+
hasRecentPosts = true;
+
}
+
}
+
+
let rate = 0;
+
if (hasRecentPosts) {
+
const days = Math.max((maxTime - minTime) / oneDay, 1);
+
rate = volume / days;
+
}
+
+
rateCache.set(did, { rate, calculatedAt: now, postCount: posts.size });
+
return rate;
+
};
+
+
export const calculateInteractionScores = (
+
user: Did,
+
followsMap: Map<ResourceUri, AppBskyGraphFollow.Main>,
+
allPosts: Map<Did, Map<ResourceUri, PostWithUri>>,
+
allBacklinks: Map<BacklinksSource, Map<ResourceUri, Map<Did, Set<string>>>>,
+
replyIndex: Map<Did, Set<ResourceUri>>,
+
now: number
+
) => {
+
const scores = new Map<Did, number>();
+
+
const decay = (time: number) => {
+
const age = Math.max(0, now - time);
+
return Math.exp(-decayLambda * age);
+
};
+
+
const addScore = (did: Did, weight: number, time: number) => {
+
const current = scores.get(did) ?? 0;
+
scores.set(did, current + weight * decay(time));
+
};
+
+
// 1. process my posts (me -> others)
+
const myPosts = allPosts.get(user);
+
if (myPosts) {
+
const seenRoots = new Set<ResourceUri>();
+
for (const post of myPosts.values()) {
+
const t = new Date(post.record.createdAt).getTime();
+
+
if (post.record.reply) {
+
const parentUri = post.record.reply.parent.uri;
+
const rootUri = post.record.reply.root.uri;
+
+
const targetDid = extractDidFromUri(parentUri);
+
if (targetDid && targetDid !== user) addScore(targetDid, replyWeight, t);
+
+
if (parentUri !== rootUri && !seenRoots.has(rootUri)) {
+
const rootDid = extractDidFromUri(rootUri);
+
if (rootDid && rootDid !== user) addScore(rootDid, replyWeight, t);
+
seenRoots.add(rootUri);
+
}
+
}
+
+
if (post.record.embed?.$type === 'app.bsky.embed.record') {
+
const targetDid = extractDidFromUri(post.record.embed.record.uri);
+
if (targetDid && targetDid !== user) addScore(targetDid, quoteWeight, t);
+
}
+
}
+
}
+
+
// 2. process others -> me (using reply index)
+
const repliesToMe = replyIndex.get(user);
+
if (repliesToMe) {
+
for (const uri of repliesToMe) {
+
const authorDid = extractDidFromUri(uri);
+
if (!authorDid || authorDid === user) continue;
+
+
const postsMap = allPosts.get(authorDid);
+
const post = postsMap?.get(uri);
+
if (!post) continue;
+
+
const t = new Date(post.record.createdAt).getTime();
+
addScore(authorDid, replyWeight, t);
+
}
+
}
+
+
// 3. process reposts on my posts
+
const repostBacklinks = allBacklinks.get(repostSource);
+
if (repostBacklinks && myPosts) {
+
for (const [uri, myPost] of myPosts) {
+
const didMap = repostBacklinks.get(uri);
+
if (!didMap) continue;
+
+
const t = new Date(myPost.record.createdAt).getTime();
+
const adds = new Map<Did, { score: number; repostCount: number }>();
+
+
for (const [did, rkeys] of didMap) {
+
if (did === user) continue;
+
+
let add = adds.get(did) ?? { score: 0, repostCount: 0 };
+
const diminishFactor = 9;
+
+
// each rkey is a separate repost record, apply diminishing returns
+
for (let i = 0; i < rkeys.size; i++) {
+
const weight = repostWeight * (diminishFactor / (add.repostCount + diminishFactor));
+
add = {
+
score: add.score + weight,
+
repostCount: add.repostCount + 1
+
};
+
}
+
adds.set(did, add);
+
}
+
+
for (const [did, add] of adds.entries()) addScore(did, add.score, t);
+
}
+
}
+
+
// normalize by posting rate
+
for (const [did, score] of scores) {
+
const posts = allPosts.get(did);
+
const rate = posts ? getPostRate(did, posts, now) : 0;
+
scores.set(did, score / Math.pow(rate + rateBaseline, ratePower));
+
}
+
+
return scores;
+
};
+39
src/lib/index.ts
···
···
+
import type {
+
CanonicalResourceUri,
+
Did,
+
ParsedCanonicalResourceUri,
+
ParsedResourceUri,
+
ResourceUri
+
} from '@atcute/lexicons';
+
import type { Backlink, BacklinksSource } from './at/constellation';
+
import { parse as parseTid } from '@atcute/tid';
+
+
export const toResourceUri = (parsed: ParsedResourceUri): ResourceUri => {
+
return `at://${parsed.repo}${parsed.collection ? `/${parsed.collection}${parsed.rkey ? `/${parsed.rkey}` : ''}` : ''}`;
+
};
+
export const toCanonicalUri = (
+
parsed: ParsedCanonicalResourceUri | Backlink
+
): CanonicalResourceUri => {
+
if ('did' in parsed) return `at://${parsed.did}/${parsed.collection}/${parsed.rkey}`;
+
return `at://${parsed.repo}/${parsed.collection}/${parsed.rkey}${parsed.fragment ? `#${parsed.fragment}` : ''}`;
+
};
+
+
export const extractDidFromUri = (uri: string): Did | null => {
+
if (!uri.startsWith('at://')) return null;
+
const idx = uri.indexOf('/', 5);
+
if (idx === -1) return uri.slice(5) as Did;
+
return uri.slice(5, idx) as Did;
+
};
+
+
export const likeSource: BacklinksSource = 'app.bsky.feed.like:subject.uri';
+
export const repostSource: BacklinksSource = 'app.bsky.feed.repost:subject.uri';
+
export const replySource: BacklinksSource = 'app.bsky.feed.post:reply.parent.uri';
+
+
export const timestampFromCursor = (cursor: string | undefined) => {
+
if (!cursor) return undefined;
+
try {
+
return parseTid(cursor).timestamp;
+
} catch {
+
return undefined;
+
}
+
};
+2 -1
src/lib/oauth.ts
···
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',
···
client_uri: domain,
logo_uri: `${domain}/favicon.png`,
redirect_uris: [`${domain}/`],
+
scope:
+
'atproto repo:*?action=create&action=update&action=delete rpc:com.atproto.repo.uploadBlob?aud=* blob:*/*',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
+15 -11
src/lib/result.ts
···
-
export type Result<T, E> =
-
| {
-
ok: true;
-
value: T;
-
}
-
| {
-
ok: false;
-
error: E;
-
};
-
export const ok = <T, E>(value: T): Result<T, E> => {
return { ok: true, value };
};
-
export const err = <T, E>(error: E): Result<T, E> => {
return { ok: false, error };
};
export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
···
}
return err(v.error);
};
···
+
export type Ok<T> = {
+
ok: true;
+
value: T;
+
};
+
export type Err<E> = {
+
ok: false;
+
error: E;
+
};
+
export type Result<T, E> = Ok<T> | Err<E>;
+
export const ok = <T>(value: T): Ok<T> => {
return { ok: true, value };
};
+
export const err = <E>(error: E): Err<E> => {
+
console.error(error);
return { ok: false, error };
};
export const expect = <T, E>(v: Result<T, E>, msg: string = 'expected result to not be error:') => {
···
}
return err(v.error);
};
+
+
export type OkType<R> = R extends { ok: true; value: infer T } ? T : never;
+
export type ErrType<R> = R extends { ok: false; error: infer E } ? E : never;
+72
src/lib/richtext/index.ts
···
···
+
import RichtextBuilder, { type BakedRichtext } from '@atcute/bluesky-richtext-builder';
+
import { tokenize, type Token } from '$lib/richtext/parser';
+
import type { Did, GenericUri, Handle } from '@atcute/lexicons';
+
import { resolveHandle } from '$lib/at/client';
+
+
export const parseToRichText = (text: string): ReturnType<typeof processTokens> =>
+
processTokens(tokenize(text));
+
+
const processTokens = async (tokens: Token[]): Promise<BakedRichtext> => {
+
const rt = new RichtextBuilder();
+
+
for (const token of tokens) {
+
switch (token.type) {
+
case 'text':
+
rt.addText(token.content);
+
break;
+
case 'mention': {
+
let did: Did | undefined = token.did as Did | undefined;
+
if (!did) {
+
const handle = token.handle as Handle;
+
const result = await resolveHandle(handle);
+
if (result.ok) did = result.value;
+
}
+
if (did) rt.addMention(token.raw, did);
+
else rt.addText(token.raw);
+
break;
+
}
+
case 'topic':
+
rt.addTag(token.name);
+
break;
+
case 'autolink':
+
rt.addLink(token.url, token.url as GenericUri);
+
break;
+
case 'link': {
+
// flatten children to text
+
const text = flattenToText(token.children);
+
rt.addLink(text, token.url as GenericUri);
+
break;
+
}
+
case 'escape':
+
rt.addText(token.escaped);
+
break;
+
// formatting tokens (strong, emphasis, etc.) don't map to facets
+
// so just extract their text content
+
case 'strong':
+
case 'emphasis':
+
case 'underline':
+
case 'delete':
+
rt.addText(flattenToText(token.children));
+
break;
+
case 'code':
+
rt.addText(token.content);
+
break;
+
case 'emote':
+
// handle emotes as needed
+
rt.addText(token.raw);
+
break;
+
}
+
}
+
+
return rt.build();
+
};
+
+
const flattenToText = (tokens: Token[]): string => {
+
return tokens
+
.map((t) => {
+
if ('content' in t) return t.content;
+
if ('children' in t) return flattenToText(t.children);
+
return t.raw;
+
})
+
.join('');
+
};
+349
src/lib/richtext/parser.ts
···
···
+
// taken and modified from: https://github.com/mary-ext/atcute/blob/trunk/packages/bluesky/richtext-parser/lib/index.ts
+
+
const ESCAPE_RE = /^\\([^0-9A-Za-z\s])/;
+
+
const MENTION_RE = /^[@๏ผ ]([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))($|\s|\p{P})/u;
+
+
const DID_RE = /^(did:([a-z0-9]+):([A-Za-z0-9.\-_%:]+))($|\s|\p{P})/u;
+
+
const TOPIC_RE =
+
/^(?:#(?!\ufe0f|\u20e3)|๏ผƒ)([\p{N}]*[\p{L}\p{M}\p{Pc}][\p{L}\p{M}\p{Pc}\p{N}]*)($|\s|\p{P})/u;
+
+
const EMOTE_RE = /^:([\w-]+):/;
+
+
const AUTOLINK_RE = /^https?:\/\/[\S]+/;
+
const AUTOLINK_BACKPEDAL_RE = /(?:(?<!\(.*)\))?[.,;]*$/;
+
+
const LINK_RE =
+
/^\[((?:\[[^\]]*\]|[^[\]]|\](?=[^[]*\]))*)\]\(\s*<?((?:\([^)]*\)|[^\s\\]|\\.)*?)>?(?:\s+['"]([^]*?)['"])?\s*\)/;
+
const UNESCAPE_URL_RE = /\\([^0-9A-Za-z\s])/g;
+
+
const EMPHASIS_RE =
+
/^\b_((?:__|\\[^]|[^\\_])+?)_\b|^\*(?=\S)((?:\*\*|\\[^]|\s+(?:\\[^]|[^\s*\\]|\*\*)|[^\s*\\])+?)\*(?!\*)/;
+
+
const STRONG_RE = /^\*\*((?:\\[^]|[^\\])+?)\*\*(?!\*)/;
+
+
const UNDERLINE_RE = /^__((?:\\[^]|~(?!~)|[^~\\]|\s(?!~~))+?)__(?!_)/;
+
+
const DELETE_RE = /^~~((?:\\[^]|~(?!~)|[^~\\]|\s(?!~~))+?)~~/;
+
+
const CODE_RE = /^(`+)([^]*?[^`])\1(?!`)/;
+
const CODE_ESCAPE_BACKTICKS_RE = /^ (?= *`)|(` *) $/g;
+
+
const TEXT_RE =
+
/^[^]+?(?:(?=$|[~*_`:\\[]|https?:\/\/)|(?<=\s|[(){}/\\[\]\-|:;'".,=+])(?=[@๏ผ #๏ผƒ]|did:[a-z0-9]+:))/;
+
+
export interface EscapeToken {
+
type: 'escape';
+
raw: string;
+
escaped: string;
+
}
+
+
export interface MentionToken {
+
type: 'mention';
+
raw: string;
+
handle?: string;
+
did?: string;
+
}
+
+
export interface TopicToken {
+
type: 'topic';
+
raw: string;
+
name: string;
+
}
+
+
export interface EmoteToken {
+
type: 'emote';
+
raw: string;
+
name: string;
+
}
+
+
export interface AutolinkToken {
+
type: 'autolink';
+
raw: string;
+
url: string;
+
}
+
+
export interface LinkToken {
+
type: 'link';
+
raw: string;
+
url: string;
+
children: Token[];
+
}
+
+
export interface UnderlineToken {
+
type: 'underline';
+
raw: string;
+
children: Token[];
+
}
+
+
export interface StrongToken {
+
type: 'strong';
+
raw: string;
+
children: Token[];
+
}
+
+
export interface EmphasisToken {
+
type: 'emphasis';
+
raw: string;
+
children: Token[];
+
}
+
+
export interface DeleteToken {
+
type: 'delete';
+
raw: string;
+
children: Token[];
+
}
+
+
export interface CodeToken {
+
type: 'code';
+
raw: string;
+
content: string;
+
}
+
+
export interface TextToken {
+
type: 'text';
+
raw: string;
+
content: string;
+
}
+
+
export type Token =
+
| EscapeToken
+
| MentionToken
+
| TopicToken
+
| EmoteToken
+
| AutolinkToken
+
| LinkToken
+
| StrongToken
+
| EmphasisToken
+
| UnderlineToken
+
| DeleteToken
+
| CodeToken
+
| TextToken;
+
+
const tokenizeEscape = (src: string): EscapeToken | undefined => {
+
const match = ESCAPE_RE.exec(src);
+
if (match) {
+
return {
+
type: 'escape',
+
raw: match[0],
+
escaped: match[1]
+
};
+
}
+
};
+
+
const tokenizeMention = (src: string): MentionToken | undefined => {
+
const match = MENTION_RE.exec(src);
+
if (match && match[2] !== '@') {
+
const suffix = match[2].length;
+
+
return {
+
type: 'mention',
+
raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0],
+
handle: match[1]
+
};
+
}
+
+
const didMatch = DID_RE.exec(src);
+
if (didMatch) {
+
const suffix = didMatch[4].length;
+
+
return {
+
type: 'mention',
+
raw: suffix > 0 ? didMatch[0].slice(0, -suffix) : didMatch[0],
+
did: didMatch[1]
+
};
+
}
+
};
+
+
const tokenizeTopic = (src: string): TopicToken | undefined => {
+
const match = TOPIC_RE.exec(src);
+
if (match && match[2] !== '#') {
+
const suffix = match[2].length;
+
+
return {
+
type: 'topic',
+
raw: suffix > 0 ? match[0].slice(0, -suffix) : match[0],
+
name: match[1]
+
};
+
}
+
};
+
+
const tokenizeEmote = (src: string): EmoteToken | undefined => {
+
const match = EMOTE_RE.exec(src);
+
if (match) {
+
return {
+
type: 'emote',
+
raw: match[0],
+
name: match[1]
+
};
+
}
+
};
+
+
const tokenizeAutolink = (src: string): AutolinkToken | undefined => {
+
const match = AUTOLINK_RE.exec(src);
+
if (match) {
+
const url = match[0].replace(AUTOLINK_BACKPEDAL_RE, '');
+
+
return {
+
type: 'autolink',
+
raw: url,
+
url: url
+
};
+
}
+
};
+
+
const tokenizeLink = (src: string): LinkToken | undefined => {
+
const match = LINK_RE.exec(src);
+
if (match) {
+
return {
+
type: 'link',
+
raw: match[0],
+
url: match[2].replace(UNESCAPE_URL_RE, '$1'),
+
children: tokenize(match[1])
+
};
+
}
+
};
+
+
const _tokenizeEmphasis = (src: string): EmphasisToken | undefined => {
+
const match = EMPHASIS_RE.exec(src);
+
if (match) {
+
return {
+
type: 'emphasis',
+
raw: match[0],
+
children: tokenize(match[2] || match[1])
+
};
+
}
+
};
+
+
const _tokenizeStrong = (src: string): StrongToken | undefined => {
+
const match = STRONG_RE.exec(src);
+
if (match) {
+
return {
+
type: 'strong',
+
raw: match[0],
+
children: tokenize(match[1])
+
};
+
}
+
};
+
+
const _tokenizeUnderline = (src: string): UnderlineToken | undefined => {
+
const match = UNDERLINE_RE.exec(src);
+
if (match) {
+
return {
+
type: 'underline',
+
raw: match[0],
+
children: tokenize(match[1])
+
};
+
}
+
};
+
+
const tokenizeEmStrongU = (
+
src: string
+
): EmphasisToken | StrongToken | UnderlineToken | undefined => {
+
let token: EmphasisToken | StrongToken | UnderlineToken | undefined;
+
+
{
+
const match = _tokenizeEmphasis(src);
+
if (match && (!token || match.raw.length > token.raw.length)) {
+
token = match;
+
}
+
}
+
+
{
+
const match = _tokenizeStrong(src);
+
if (match && (!token || match.raw.length > token.raw.length)) {
+
token = match;
+
}
+
}
+
+
{
+
const match = _tokenizeUnderline(src);
+
if (match && (!token || match.raw.length > token.raw.length)) {
+
token = match;
+
}
+
}
+
+
return token;
+
};
+
+
const tokenizeDelete = (src: string): DeleteToken | undefined => {
+
const match = DELETE_RE.exec(src);
+
if (match) {
+
return {
+
type: 'delete',
+
raw: match[0],
+
children: tokenize(match[1])
+
};
+
}
+
};
+
+
const tokenizeCode = (src: string): CodeToken | undefined => {
+
const match = CODE_RE.exec(src);
+
if (match) {
+
return {
+
type: 'code',
+
raw: match[0],
+
content: match[2].replace(CODE_ESCAPE_BACKTICKS_RE, '$1')
+
};
+
}
+
};
+
+
const tokenizeText = (src: string): TextToken | undefined => {
+
const match = TEXT_RE.exec(src);
+
if (match) {
+
return {
+
type: 'text',
+
raw: match[0],
+
content: match[0]
+
};
+
}
+
};
+
+
export const tokenize = (src: string): Token[] => {
+
const tokens: Token[] = [];
+
+
let last: Token | undefined;
+
let token: Token | undefined;
+
+
while (src) {
+
last = token;
+
+
if (
+
(token =
+
tokenizeEscape(src) ||
+
tokenizeMention(src) ||
+
tokenizeAutolink(src) ||
+
tokenizeTopic(src) ||
+
tokenizeEmote(src) ||
+
tokenizeLink(src) ||
+
tokenizeEmStrongU(src) ||
+
tokenizeDelete(src) ||
+
tokenizeCode(src))
+
) {
+
src = src.slice(token.raw.length);
+
tokens.push(token);
+
continue;
+
}
+
+
if ((token = tokenizeText(src))) {
+
src = src.slice(token.raw.length);
+
+
if (last && last.type === 'text') {
+
last.raw += token.raw;
+
last.content += token.content;
+
token = last;
+
} else {
+
tokens.push(token);
+
}
+
+
continue;
+
}
+
+
if (src) {
+
throw new Error(`infinite loop encountered`);
+
}
+
}
+
+
return tokens;
+
};
+156
src/lib/router.svelte.ts
···
···
+
/* eslint-disable svelte/no-navigation-without-resolve */
+
import { pushState, replaceState } from '$app/navigation';
+
import { SvelteMap } from 'svelte/reactivity';
+
+
export const routes = [
+
{ path: '/', order: 0 },
+
{ path: '/following', order: 1 },
+
{ path: '/notifications', order: 2 },
+
{ path: '/settings/:tab', order: 3 },
+
{ path: '/profile/:actor', order: 4 }
+
] as const;
+
+
export type RouteConfig = (typeof routes)[number];
+
export type RoutePath = RouteConfig['path'];
+
+
type ExtractParams<Path extends string> =
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+
Path extends `${infer Start}/:${infer Param}/${infer Rest}`
+
? { [K in Param | keyof ExtractParams<`/${Rest}`>]: string }
+
: // eslint-disable-next-line @typescript-eslint/no-unused-vars
+
Path extends `${infer Start}/:${infer Param}`
+
? { [K in Param]: string }
+
: Record<string, never>;
+
+
export type Route<K extends RoutePath = RoutePath> = {
+
[T in K]: {
+
params: ExtractParams<T>;
+
path: T;
+
order: number;
+
url: string;
+
};
+
}[K];
+
+
type RouteNode = {
+
children: Map<string, RouteNode>;
+
paramName?: string;
+
paramChild?: RouteNode;
+
config?: RouteConfig;
+
};
+
+
const fallbackRoute: Route<'/'> = {
+
params: {},
+
path: '/',
+
order: 0,
+
url: '/'
+
};
+
+
export class Router {
+
current = $state<Route>(fallbackRoute);
+
+
direction = $state<'left' | 'right' | 'none'>('none');
+
scrollPositions = new SvelteMap<string, number>();
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
+
private root: RouteNode = { children: new Map() };
+
+
constructor() {
+
for (const route of routes) this.addRoute(route);
+
}
+
+
private addRoute(config: RouteConfig) {
+
const segments = config.path.split('/').filter(Boolean);
+
let node = this.root;
+
+
for (const segment of segments) {
+
if (segment.startsWith(':')) {
+
const paramName = segment.slice(1);
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
+
if (!node.paramChild) node.paramChild = { children: new Map(), paramName };
+
node = node.paramChild;
+
} else {
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
+
if (!node.children.has(segment)) node.children.set(segment, { children: new Map() });
+
node = node.children.get(segment)!;
+
}
+
}
+
node.config = config;
+
}
+
+
init() {
+
if (typeof window === 'undefined') return;
+
// initialize state
+
this._updateState(window.location.pathname);
+
// update state on browser navigation
+
window.addEventListener('popstate', () => this._updateState(window.location.pathname));
+
}
+
+
match(urlPath: string): Route | undefined {
+
const segments = urlPath.split('/').filter(Boolean);
+
const params: Record<string, string> = {};
+
+
let node = this.root;
+
+
for (const segment of segments) {
+
if (node.children.has(segment)) {
+
node = node.children.get(segment)!;
+
} else if (node.paramChild) {
+
node = node.paramChild;
+
if (node.paramName) params[node.paramName] = decodeURIComponent(segment);
+
} else {
+
return undefined;
+
}
+
}
+
+
if (node.config)
+
return {
+
params: params as unknown,
+
path: node.config.path,
+
order: node.config.order,
+
url: urlPath
+
} as Route<typeof node.config.path>;
+
+
return undefined;
+
}
+
+
updateDirection(newOrder: number, oldOrder: number) {
+
if (newOrder === oldOrder) this.direction = 'none';
+
else if (newOrder > oldOrder) this.direction = 'right';
+
else this.direction = 'left';
+
}
+
+
private _updateState(url: string) {
+
const target = this.match(url);
+
if (!target) return;
+
+
// save scroll position
+
if (typeof window !== 'undefined') this.scrollPositions.set(this.current.url, window.scrollY);
+
+
this.updateDirection(target.order, this.current.order);
+
this.current = target;
+
+
if (typeof window !== 'undefined') {
+
setTimeout(() => {
+
const savedScroll = this.scrollPositions.get(target.url) ?? 0;
+
window.scrollTo({ top: savedScroll, behavior: 'auto' });
+
}, 0);
+
}
+
}
+
+
navigate(url: string, { replace = false } = {}) {
+
if (typeof window === 'undefined') return;
+
if (this.current.url === url) return;
+
+
if (replace) replaceState(url, {});
+
else pushState(url, {});
+
+
this._updateState(url);
+
}
+
+
replace(url: string) {
+
this.navigate(url, { replace: true });
+
}
+
+
back() {
+
if (typeof window !== 'undefined') history.back();
+
}
+
}
+24 -8
src/lib/settings.ts
···
slingshot: string;
spacedust: string;
constellation: string;
};
export type Settings = {
endpoints: ApiEndpoints;
···
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);
}
};
···
return (
current.endpoints.slingshot !== other.endpoints.slingshot ||
current.endpoints.spacedust !== other.endpoints.spacedust ||
-
current.endpoints.constellation !== other.endpoints.constellation
);
};
···
slingshot: string;
spacedust: string;
constellation: string;
+
jetstream: string;
};
export type Settings = {
endpoints: ApiEndpoints;
···
endpoints: {
slingshot: 'https://slingshot.microcosm.blue',
spacedust: 'https://spacedust.microcosm.blue',
+
constellation: 'https://constellation.microcosm.blue',
+
jetstream: 'wss://jetstream2.fr.hose.cam'
},
theme: defaultTheme,
socialAppUrl: 'https://bsky.app'
};
const createSettingsStore = () => {
+
// Prevent SSR crash if localStorage is missing
+
const stored = typeof localStorage !== 'undefined' ? localStorage.getItem('settings') : null;
const initial: Partial<Settings> = stored ? JSON.parse(stored) : defaultSettings;
+
initial.endpoints = { ...defaultSettings.endpoints, ...initial.endpoints };
+
initial.theme = { ...defaultSettings.theme, ...initial.theme };
initial.socialAppUrl = initial.socialAppUrl ?? defaultSettings.socialAppUrl;
const { subscribe, set, update } = writable<Settings>(initial as Settings);
subscribe((settings) => {
+
if (typeof document === 'undefined') return;
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);
+
+
const oldMeta = document.querySelector('meta[name="theme-color"]');
+
if (oldMeta) oldMeta.remove();
+
+
const metaThemeColor = document.createElement('meta');
+
metaThemeColor.setAttribute('name', 'theme-color');
+
metaThemeColor.setAttribute('content', theme.bg);
+
document.head.appendChild(metaThemeColor);
});
return {
subscribe,
set: (value: Settings) => {
+
if (typeof localStorage !== 'undefined')
+
localStorage.setItem('settings', JSON.stringify(value));
set(value);
},
update: (fn: (value: Settings) => Settings) => {
update((value) => {
const newValue = fn(value);
+
if (typeof localStorage !== 'undefined')
+
localStorage.setItem('settings', JSON.stringify(newValue));
return newValue;
});
},
reset: () => {
+
if (typeof localStorage !== 'undefined')
+
localStorage.setItem('settings', JSON.stringify(defaultSettings));
set(defaultSettings);
}
};
···
return (
current.endpoints.slingshot !== other.endpoints.slingshot ||
current.endpoints.spacedust !== other.endpoints.spacedust ||
+
current.endpoints.constellation !== other.endpoints.constellation ||
+
current.endpoints.jetstream !== other.endpoints.jetstream
);
};
+480 -17
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 }>();
···
import { writable } from 'svelte/store';
+
import {
+
AtpClient,
+
newPublicClient,
+
type NotificationsStream,
+
type NotificationsStreamEvent
+
} from './at/client';
+
import { SvelteMap, SvelteDate, SvelteSet } from 'svelte/reactivity';
+
import type { Did, Handle, InferOutput, Nsid, RecordKey, ResourceUri } from '@atcute/lexicons';
+
import { fetchPosts, hydratePosts, type PostWithUri } from './at/fetch';
+
import { parseCanonicalResourceUri, type AtprotoDid } from '@atcute/lexicons/syntax';
+
import { AppBskyActorProfile, AppBskyFeedPost, type AppBskyGraphFollow } from '@atcute/bluesky';
+
import type { ComAtprotoRepoListRecords } from '@atcute/atproto';
+
import type { JetstreamSubscription, JetstreamEvent } from '@atcute/jetstream';
+
import { expect, ok } from './result';
+
import type { Backlink, BacklinksSource } from './at/constellation';
+
import { now as tidNow } from '@atcute/tid';
+
import type { Records } from '@atcute/lexicons/ambient';
+
import {
+
extractDidFromUri,
+
likeSource,
+
replySource,
+
repostSource,
+
timestampFromCursor,
+
toCanonicalUri
+
} from '$lib';
+
import { Router } from './router.svelte';
export const notificationStream = writable<NotificationsStream | null>(null);
+
export const jetstream = writable<JetstreamSubscription | null>(null);
+
+
export const profiles = new SvelteMap<Did, AppBskyActorProfile.Main>();
+
export const handles = new SvelteMap<Did, Handle>();
+
+
// source -> subject -> did (who did the interaction) -> rkey
+
export type BacklinksMap = SvelteMap<
+
BacklinksSource,
+
SvelteMap<ResourceUri, SvelteMap<Did, SvelteSet<RecordKey>>>
+
>;
+
export const allBacklinks: BacklinksMap = new SvelteMap();
+
+
export const addBacklinks = (
+
subject: ResourceUri,
+
source: BacklinksSource,
+
links: Iterable<Backlink>
+
) => {
+
let subjectMap = allBacklinks.get(source);
+
if (!subjectMap) {
+
subjectMap = new SvelteMap();
+
allBacklinks.set(source, subjectMap);
+
}
+
+
let didMap = subjectMap.get(subject);
+
if (!didMap) {
+
didMap = new SvelteMap();
+
subjectMap.set(subject, didMap);
+
}
+
+
for (const link of links) {
+
let rkeys = didMap.get(link.did);
+
if (!rkeys) {
+
rkeys = new SvelteSet();
+
didMap.set(link.did, rkeys);
+
}
+
rkeys.add(link.rkey);
+
}
+
};
+
+
export const removeBacklinks = (
+
subject: ResourceUri,
+
source: BacklinksSource,
+
links: Iterable<Backlink>
+
) => {
+
const didMap = allBacklinks.get(source)?.get(subject);
+
if (!didMap) return;
+
+
for (const link of links) {
+
const rkeys = didMap.get(link.did);
+
if (!rkeys) continue;
+
rkeys.delete(link.rkey);
+
if (rkeys.size === 0) didMap.delete(link.did);
+
}
+
};
+
+
export const findBacklinksBy = (subject: ResourceUri, source: BacklinksSource, did: Did) => {
+
const rkeys = allBacklinks.get(source)?.get(subject)?.get(did) ?? [];
+
// reconstruct the collection from the source
+
const collection = source.split(':')[0] as Nsid;
+
return rkeys.values().map((rkey) => ({ did, collection, rkey }));
+
};
+
+
export const hasBacklink = (subject: ResourceUri, source: BacklinksSource, did: Did): boolean => {
+
return allBacklinks.get(source)?.get(subject)?.has(did) ?? false;
+
};
+
+
export const getAllBacklinksFor = (subject: ResourceUri, source: BacklinksSource): Backlink[] => {
+
const subjectMap = allBacklinks.get(source);
+
if (!subjectMap) return [];
+
+
const didMap = subjectMap.get(subject);
+
if (!didMap) return [];
+
+
const collection = source.split(':')[0] as Nsid;
+
const result: Backlink[] = [];
+
+
for (const [did, rkeys] of didMap)
+
for (const rkey of rkeys) result.push({ did, collection, rkey });
+
+
return result;
+
};
+
+
export const isBlockedBy = (subject: Did, blocker: Did): boolean => {
+
return hasBacklink(`at://${subject}`, 'app.bsky.graph.block:subject', blocker);
+
};
+
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
const getNestedValue = (obj: any, path: string[]): any => {
+
return path.reduce((current, key) => current?.[key], obj);
+
};
+
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
+
const setNestedValue = (obj: any, path: string[], value: any): void => {
+
const lastKey = path[path.length - 1];
+
const parent = path.slice(0, -1).reduce((current, key) => {
+
if (current[key] === undefined) current[key] = {};
+
return current[key];
+
}, obj);
+
parent[lastKey] = value;
+
};
+
+
export const backlinksCursors = new SvelteMap<
+
Did,
+
SvelteMap<BacklinksSource, string | undefined>
+
>();
+
+
export const fetchLinksUntil = async (
+
client: AtpClient,
+
backlinkSource: BacklinksSource,
+
timestamp: number = -1
+
) => {
+
const did = client.user?.did;
+
if (!did) return;
+
let cursorMap = backlinksCursors.get(did);
+
if (!cursorMap) {
+
cursorMap = new SvelteMap<BacklinksSource, string | undefined>();
+
backlinksCursors.set(did, cursorMap);
+
}
+
+
const [_collection, source] = backlinkSource.split(':');
+
const collection = _collection as keyof Records;
+
const cursor = cursorMap.get(backlinkSource);
+
+
// if already fetched we dont need to fetch again
+
const cursorTimestamp = timestampFromCursor(cursor);
+
if (cursorTimestamp && cursorTimestamp <= timestamp) return;
+
+
console.log(`${did}: fetchLinksUntil`, backlinkSource, cursor, timestamp);
+
const result = await client.listRecordsUntil(collection, cursor, timestamp);
+
+
if (!result.ok) {
+
console.error('failed to fetch links until', result.error);
+
return;
+
}
+
cursorMap.set(backlinkSource, result.value.cursor);
+
+
const path = source.split('.');
+
for (const record of result.value.records) {
+
const uri = getNestedValue(record.value, path);
+
const parsedUri = parseCanonicalResourceUri(record.uri);
+
if (!parsedUri.ok) continue;
+
addBacklinks(uri, `${collection}:${source}`, [
+
{
+
did: parsedUri.value.repo,
+
collection: parsedUri.value.collection,
+
rkey: parsedUri.value.rkey
+
}
+
]);
+
}
};
+
+
export const deletePostBacklink = async (
+
client: AtpClient,
+
post: PostWithUri,
+
source: BacklinksSource
+
) => {
+
const did = client.user?.did;
+
if (!did) return;
+
const collection = source.split(':')[0] as Nsid;
+
const links = findBacklinksBy(post.uri, source, did);
+
removeBacklinks(post.uri, source, links);
+
await Promise.allSettled(
+
links.map((link) =>
+
client.atcute?.post('com.atproto.repo.deleteRecord', {
+
input: { repo: did, collection, rkey: link.rkey! }
+
})
+
)
+
);
+
};
+
+
export const createPostBacklink = async (
+
client: AtpClient,
+
post: PostWithUri,
+
source: BacklinksSource
+
) => {
+
const did = client.user?.did;
+
if (!did) return;
+
const [_collection, subject] = source.split(':');
+
const collection = _collection as Nsid;
+
const rkey = tidNow();
+
addBacklinks(post.uri, source, [
+
{
+
did,
+
collection,
+
rkey
+
}
+
]);
+
const record = {
+
$type: collection,
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
+
createdAt: new Date().toISOString()
+
};
+
const subjectPath = subject.split('.');
+
setNestedValue(record, subjectPath, post.uri);
+
setNestedValue(record, [...subjectPath.slice(0, -1), 'cid'], post.cid);
+
await client.atcute?.post('com.atproto.repo.createRecord', {
+
input: {
+
repo: did,
+
collection,
+
rkey,
+
record
+
}
+
});
+
};
export const pulsingPostId = writable<string | null>(null);
export const viewClient = new AtpClient();
+
export const clients = new SvelteMap<Did, AtpClient>();
+
export const getClient = async (did: Did): Promise<AtpClient> => {
+
if (!clients.has(did)) clients.set(did, await newPublicClient(did));
+
return clients.get(did)!;
+
};
+
+
export const follows = new SvelteMap<Did, SvelteMap<ResourceUri, AppBskyGraphFollow.Main>>();
+
+
export const addFollows = (
+
did: Did,
+
followMap: Iterable<[ResourceUri, AppBskyGraphFollow.Main]>
+
) => {
+
if (!follows.has(did)) {
+
follows.set(did, new SvelteMap(followMap));
+
return;
+
}
+
const map = follows.get(did)!;
+
for (const [uri, record] of followMap) map.set(uri, record);
+
};
+
+
export const fetchFollows = async (did: AtprotoDid) => {
+
const client = await getClient(did);
+
const res = await client.listRecordsUntil('app.bsky.graph.follow');
+
if (!res.ok) return;
+
addFollows(
+
did,
+
res.value.records.map((follow) => [follow.uri, follow.value as AppBskyGraphFollow.Main])
+
);
+
};
+
+
// this fetches up to three days of posts and interactions for using in following list
+
export const fetchForInteractions = async (did: AtprotoDid) => {
+
const threeDaysAgo = (Date.now() - 3 * 24 * 60 * 60 * 1000) * 1000;
+
const client = await getClient(did);
+
const res = await client.listRecordsUntil('app.bsky.feed.post', undefined, threeDaysAgo);
+
if (!res.ok) return;
+
addPostsRaw(did, res.value);
+
+
const cursorTimestamp = timestampFromCursor(res.value.cursor) ?? -1;
+
const timestamp = Math.min(cursorTimestamp, threeDaysAgo);
+
console.log(`${did}: fetchForInteractions`, res.value.cursor, timestamp);
+
await Promise.all([repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
+
};
+
+
export const allPosts = new SvelteMap<Did, SvelteMap<ResourceUri, PostWithUri>>();
+
// did -> post uris that are replies to that did
+
export const replyIndex = new SvelteMap<Did, SvelteSet<ResourceUri>>();
+
+
export const getPost = (did: Did, rkey: RecordKey) =>
+
allPosts.get(did)?.get(toCanonicalUri({ did, collection: 'app.bsky.feed.post', rkey }));
+
const hydrateCacheFn: Parameters<typeof hydratePosts>[3] = (did, rkey) => {
+
const cached = getPost(did, rkey);
+
return cached ? ok(cached) : undefined;
+
};
+
+
export const addPostsRaw = (
+
did: AtprotoDid,
+
newPosts: InferOutput<ComAtprotoRepoListRecords.mainSchema['output']['schema']>
+
) => {
+
const postsWithUri = newPosts.records.map(
+
(post) =>
+
({ cid: post.cid, uri: post.uri, record: post.value as AppBskyFeedPost.Main }) as PostWithUri
+
);
+
addPosts(postsWithUri);
+
};
+
+
export const addPosts = (newPosts: Iterable<PostWithUri>) => {
+
for (const post of newPosts) {
+
const parsedUri = expect(parseCanonicalResourceUri(post.uri));
+
let posts = allPosts.get(parsedUri.repo);
+
if (!posts) {
+
posts = new SvelteMap();
+
allPosts.set(parsedUri.repo, posts);
+
}
+
posts.set(post.uri, post);
+
if (post.record.reply) {
+
addBacklinks(post.record.reply.parent.uri, replySource, [
+
{
+
did: parsedUri.repo,
+
collection: parsedUri.collection,
+
rkey: parsedUri.rkey
+
}
+
]);
+
+
// update reply index
+
const parentDid = extractDidFromUri(post.record.reply.parent.uri);
+
if (parentDid) {
+
let set = replyIndex.get(parentDid);
+
if (!set) {
+
set = new SvelteSet();
+
replyIndex.set(parentDid, set);
+
}
+
set.add(post.uri);
+
}
+
}
+
}
+
};
+
+
export const timelines = new SvelteMap<Did, SvelteSet<ResourceUri>>();
+
export const postCursors = new SvelteMap<Did, { value?: string; end: boolean }>();
+
+
const traversePostChain = (post: PostWithUri) => {
+
const result = [post.uri];
+
const parentUri = post.record.reply?.parent.uri;
+
if (parentUri) {
+
const parentPost = allPosts.get(extractDidFromUri(parentUri)!)?.get(parentUri);
+
if (parentPost) result.push(...traversePostChain(parentPost));
+
}
+
return result;
+
};
+
export const addTimeline = (did: Did, uris: Iterable<ResourceUri>) => {
+
let timeline = timelines.get(did);
+
if (!timeline) {
+
timeline = new SvelteSet();
+
timelines.set(did, timeline);
+
}
+
for (const uri of uris) {
+
const post = allPosts.get(did)?.get(uri);
+
// we need to traverse the post chain to add all posts in the chain to the timeline
+
// because the parent posts might not be in the timeline yet
+
const chain = post ? traversePostChain(post) : [uri];
+
for (const uri of chain) timeline.add(uri);
+
}
+
};
+
+
export const fetchTimeline = async (
+
did: AtprotoDid,
+
limit: number = 6,
+
withBacklinks: boolean = true
+
) => {
+
const targetClient = await getClient(did);
+
+
const cursor = postCursors.get(did);
+
if (cursor && cursor.end) return;
+
+
const accPosts = await fetchPosts(targetClient, cursor?.value, limit, withBacklinks);
+
if (!accPosts.ok) throw `cant fetch posts ${did}: ${accPosts.error}`;
+
+
// if the cursor is undefined, we've reached the end of the timeline
+
postCursors.set(did, { value: accPosts.value.cursor, end: !accPosts.value.cursor });
+
const hydrated = await hydratePosts(targetClient, did, accPosts.value.posts, hydrateCacheFn);
+
if (!hydrated.ok) throw `cant hydrate posts ${did}: ${hydrated.error}`;
+
+
addPosts(hydrated.value.values());
+
addTimeline(did, hydrated.value.keys());
+
+
console.log(`${did}: fetchTimeline`, accPosts.value.cursor);
+
};
+
+
export const fetchInteractionsUntil = async (client: AtpClient, did: Did) => {
+
const cursor = postCursors.get(did);
+
if (!cursor) return;
+
const timestamp = timestampFromCursor(cursor.value);
+
await Promise.all([likeSource, repostSource].map((s) => fetchLinksUntil(client, s, timestamp)));
+
};
+
+
export const handleJetstreamEvent = async (event: JetstreamEvent) => {
+
if (event.kind !== 'commit') return;
+
+
const { did, commit } = event;
+
const uri: ResourceUri = toCanonicalUri({ did, ...commit });
+
if (commit.collection === 'app.bsky.feed.post') {
+
if (commit.operation === 'create') {
+
const posts = [
+
{
+
record: commit.record as AppBskyFeedPost.Main,
+
uri,
+
cid: commit.cid
+
}
+
];
+
const client = await getClient(did);
+
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
+
if (!hydrated.ok) {
+
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
+
return;
+
}
+
addPosts(hydrated.value.values());
+
addTimeline(did, hydrated.value.keys());
+
} else if (commit.operation === 'delete') {
+
allPosts.get(did)?.delete(uri);
+
}
+
}
+
};
+
+
const handlePostNotification = async (event: NotificationsStreamEvent & { type: 'message' }) => {
+
const parsedSubjectUri = expect(parseCanonicalResourceUri(event.data.link.subject));
+
const did = parsedSubjectUri.repo as AtprotoDid;
+
const client = await getClient(did);
+
const subjectPost = await client.getRecord(
+
AppBskyFeedPost.mainSchema,
+
did,
+
parsedSubjectUri.rkey
+
);
+
if (!subjectPost.ok) return;
+
+
const parsedSourceUri = expect(parseCanonicalResourceUri(event.data.link.source_record));
+
const posts = [
+
{
+
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
+
}
+
]
+
}
+
}
+
];
+
const hydrated = await hydratePosts(client, did, posts, hydrateCacheFn);
+
if (!hydrated.ok) {
+
console.error(`cant hydrate posts ${did}: ${hydrated.error}`);
+
return;
+
}
+
+
// console.log(hydrated);
+
addPosts(hydrated.value.values());
+
addTimeline(did, hydrated.value.keys());
+
};
+
+
const handleBacklink = (event: NotificationsStreamEvent & { type: 'message' }) => {
+
const parsedSource = expect(parseCanonicalResourceUri(event.data.link.source_record));
+
addBacklinks(event.data.link.subject, event.data.link.source, [
+
{
+
did: parsedSource.repo,
+
collection: parsedSource.collection,
+
rkey: parsedSource.rkey
+
}
+
]);
+
};
+
+
export const handleNotification = async (event: NotificationsStreamEvent) => {
+
if (event.type === 'message') {
+
if (event.data.link.source.startsWith('app.bsky.feed.post')) handlePostNotification(event);
+
else handleBacklink(event);
+
}
+
};
+
+
export const currentTime = new SvelteDate();
+
+
if (typeof window !== 'undefined')
+
setInterval(() => {
+
currentTime.setTime(Date.now());
+
}, 1000);
+
+
export const router = new Router();
+1 -3
src/lib/theme.ts
···
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
···
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
+24 -18
src/lib/thread.ts
···
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[] = [];
···
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
threads.filter((thread) => {
if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
return true;
});
···
branchParentPost?: ThreadPost;
};
+
export const buildThreads = (
+
account: Did,
+
timeline: Set<ResourceUri>,
+
posts: Map<Did, Map<ResourceUri, PostWithUri>>
+
): Thread[] => {
const threadMap = new Map<ResourceUri, ThreadPost[]>();
// group posts by root uri into "thread" chains
+
for (const uri of timeline) {
+
const parsedUri = expect(parseCanonicalResourceUri(uri));
+
const data = posts.get(parsedUri.repo)?.get(uri);
+
if (!data) continue;
+
+
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[] = [];
···
export const filterThreads = (threads: Thread[], accounts: Account[], opts: FilterOptions) =>
threads.filter((thread) => {
+
if (thread.posts.length === 0) return false;
if (!opts.viewOwnPosts) return hasNonOwnPost(thread.posts, accounts);
return true;
});
-489
src/routes/+page.svelte
···
-
<script lang="ts">
-
import BskyPost from '$components/BskyPost.svelte';
-
import PostComposer from '$components/PostComposer.svelte';
-
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>
-
-
{#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);
-
};
···
+360
src/routes/[...catchall]/+page.svelte
···
···
+
<script lang="ts">
+
import PostComposer, { type State as PostComposerState } from '$components/PostComposer.svelte';
+
import AccountSelector from '$components/AccountSelector.svelte';
+
import SettingsView from '$components/SettingsView.svelte';
+
import NotificationsView from '$components/NotificationsView.svelte';
+
import FollowingView from '$components/FollowingView.svelte';
+
import TimelineView from '$components/TimelineView.svelte';
+
import ProfileView from '$components/ProfileView.svelte';
+
import { AtpClient, streamNotifications } from '$lib/at/client';
+
import { accounts, type Account } from '$lib/accounts';
+
import { onMount } from 'svelte';
+
import {
+
clients,
+
postCursors,
+
fetchForInteractions,
+
fetchFollows,
+
follows,
+
notificationStream,
+
viewClient,
+
jetstream,
+
handleJetstreamEvent,
+
handleNotification,
+
addPosts,
+
addTimeline,
+
router
+
} from '$lib/state.svelte';
+
import { get } from 'svelte/store';
+
import Icon from '@iconify/svelte';
+
import { sessions } from '$lib/at/oauth';
+
import type { AtprotoDid, Did } from '@atcute/lexicons/syntax';
+
import type { PageProps } from './+page';
+
import { JetstreamSubscription } from '@atcute/jetstream';
+
import { settings } from '$lib/settings';
+
import type { Sort } from '$lib/following';
+
import { SvelteMap } from 'svelte/reactivity';
+
+
const { data: loadData }: PageProps = $props();
+
+
const currentRoute = $derived(router.current);
+
+
// svelte-ignore state_referenced_locally
+
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) : undefined);
+
+
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);
+
postCursors.delete(did);
+
handleAccountSelected(newAccounts[0]?.did);
+
};
+
+
let followingSort = $state('active' as Sort);
+
+
// Animation logic derived from router direction
+
let animClass = $state('animate-fade-in-scale');
+
$effect(() => {
+
if (router.direction === 'right') animClass = 'animate-slide-in-right';
+
else if (router.direction === 'left') animClass = 'animate-slide-in-left';
+
else animClass = 'animate-fade-in-scale';
+
});
+
+
let postComposerState = $state<PostComposerState>({
+
focus: 'null',
+
text: '',
+
blobsState: new SvelteMap()
+
});
+
let showScrollToTop = $state(false);
+
const handleScroll = () => {
+
if (currentRoute.path === '/' || currentRoute.path === '/profile/:actor')
+
showScrollToTop = window.scrollY > 300;
+
};
+
const scrollToTop = () => window.scrollTo({ top: 0, behavior: 'smooth' });
+
+
onMount(() => {
+
router.init();
+
+
window.addEventListener('scroll', handleScroll);
+
+
accounts.subscribe((newAccounts) => {
+
get(notificationStream)?.stop();
+
// jetstream.set(null);
+
if (newAccounts.length === 0) return;
+
notificationStream.set(
+
streamNotifications(
+
newAccounts.map((account) => account.did),
+
'app.bsky.feed.post:reply.parent.uri',
+
'app.bsky.feed.post:embed.record.record.uri',
+
'app.bsky.feed.post:embed.record.uri',
+
'app.bsky.feed.repost:subject.uri',
+
'app.bsky.feed.like:subject.uri',
+
'app.bsky.graph.follow:subject'
+
)
+
);
+
});
+
notificationStream.subscribe((stream) => {
+
if (!stream) return;
+
stream.listen(handleNotification);
+
});
+
+
console.log(`creating jetstream subscription to ${$settings.endpoints.jetstream}`);
+
const jetstreamSub = new JetstreamSubscription({
+
url: $settings.endpoints.jetstream,
+
wantedCollections: ['app.bsky.feed.post'],
+
// this is here because if wantedDids is zero jetstream will send all events
+
wantedDids: ['did:web:guestbook.gaze.systems']
+
});
+
jetstream.set(jetstreamSub);
+
+
(async () => {
+
console.log('polling for jetstream...');
+
for await (const event of jetstreamSub) handleJetstreamEvent(event);
+
})();
+
+
if ($accounts.length > 0) {
+
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(() => {
+
$accounts.forEach((account) => {
+
fetchFollows(account.did).then(() =>
+
follows
+
.get(account.did)
+
?.forEach((follow) => fetchForInteractions(follow.subject as AtprotoDid))
+
);
+
fetchForInteractions(account.did);
+
});
+
});
+
} else {
+
selectedDid = null;
+
}
+
+
return () => window.removeEventListener('scroll', handleScroll);
+
});
+
+
$effect(() => {
+
const wantedDids: Did[] = ['did:web:guestbook.gaze.systems'];
+
+
for (const followMap of follows.values())
+
for (const follow of followMap.values()) wantedDids.push(follow.subject);
+
for (const account of $accounts) wantedDids.push(account.did);
+
+
// console.log('updating jetstream options:', wantedDids);
+
$jetstream?.updateOptions({ wantedDids });
+
});
+
</script>
+
+
{#snippet appButton(
+
onClick: () => void,
+
icon: string,
+
ariaLabel: string,
+
isActive: boolean,
+
iconHover?: string
+
)}
+
<button
+
onclick={onClick}
+
class="group rounded-sm p-2 transition-all hover:scale-110 hover:shadow-lg
+
{isActive
+
? 'bg-(--nucleus-accent)/25 text-(--nucleus-accent)'
+
: 'bg-(--nucleus-accent)/10 text-(--nucleus-accent) hover:bg-(--nucleus-accent)/15'}"
+
aria-label={ariaLabel}
+
>
+
<Icon class="group-hover:hidden" {icon} width={28} />
+
<Icon class="hidden group-hover:block" icon={iconHover ?? icon} width={28} />
+
</button>
+
{/snippet}
+
+
{#snippet routeButton({
+
route,
+
path = route,
+
icon,
+
iconHover = `${icon}-solid`,
+
ariaLabel = path.split('/').pop() ?? path
+
}: {
+
route: (typeof currentRoute)['path'];
+
path?: string;
+
icon: string;
+
ariaLabel?: string;
+
iconHover?: string;
+
})}
+
{@render appButton(
+
() => router.navigate(path),
+
icon,
+
ariaLabel,
+
currentRoute.path === route,
+
iconHover
+
)}
+
{/snippet}
+
+
<div class="mx-auto flex min-h-dvh max-w-2xl flex-col">
+
<div class="flex-1">
+
<!-- timeline -->
+
<TimelineView
+
class={currentRoute.path === '/' ? `${animClass}` : 'hidden'}
+
client={selectedClient}
+
showReplies={true}
+
bind:postComposerState
+
/>
+
+
{#if currentRoute.path === '/settings/:tab'}
+
<div class={animClass}>
+
<SettingsView tab={currentRoute.params.tab} />
+
</div>
+
{:else if currentRoute.path === '/notifications'}
+
<div class={animClass}>
+
<NotificationsView />
+
</div>
+
{:else if currentRoute.path === '/following'}
+
<div class={animClass}>
+
<FollowingView client={selectedClient} bind:followingSort />
+
</div>
+
{:else if currentRoute.path === '/profile/:actor'}
+
{#key currentRoute.params.actor}
+
<div class={animClass}>
+
<ProfileView
+
client={selectedClient ?? viewClient}
+
onBack={() => router.back()}
+
actor={currentRoute.params.actor}
+
bind:postComposerState
+
/>
+
</div>
+
{/key}
+
{/if}
+
</div>
+
+
<!-- header / footer -->
+
<div id="app-footer" class="sticky bottom-0 z-10 mt-4">
+
{#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="
+
{['/', '/following', '/profile/:actor'].includes(router.current.path) ? '' : 'hidden'}
+
z-20 w-full max-w-2xl p-2.5 px-4 pb-1 transition-all
+
"
+
>
+
<!-- composer and error disclaimer (above thread list, not scrollable) -->
+
<div class="footer-border-bg rounded-sm px-0.5 py-0.5">
+
<div class="footer-bg flex gap-2 rounded-sm p-1.5 shadow-2xl">
+
<AccountSelector
+
client={viewClient}
+
accounts={$accounts}
+
bind:selectedDid
+
onAccountSelected={handleAccountSelected}
+
onLogout={handleLogout}
+
/>
+
+
{#if selectedClient}
+
<div class="flex-1">
+
<PostComposer
+
client={selectedClient}
+
onPostSent={(post) => {
+
addPosts([post]);
+
addTimeline(selectedDid!, [post.uri]);
+
}}
+
bind:_state={postComposerState}
+
/>
+
</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 postComposerState.focus === 'null' && showScrollToTop}
+
{@render appButton(scrollToTop, 'heroicons:arrow-up-16-solid', 'scroll to top', false)}
+
{/if}
+
</div>
+
</div>
+
</div>
+
+
<div id="footer-portal" class="contents"></div>
+
+
<div class="footer-border-bg rounded-t-sm px-0.5 pt-0.5">
+
<div class="footer-bg rounded-t-sm">
+
<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 routeButton({ route: '/', icon: 'heroicons:home' })}
+
{@render routeButton({ route: '/following', icon: 'heroicons:users' })}
+
{@render routeButton({ route: '/notifications', icon: 'heroicons:bell' })}
+
{@render routeButton({
+
path: '/settings/advanced',
+
route: '/settings/:tab',
+
icon: 'heroicons:cog-6-tooth'
+
})}
+
</div>
+
</div>
+
</div>
+
</div>
+
</div>
+
+
<style>
+
.footer-bg {
+
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))
+
);
+
}
+
+
.footer-border-bg {
+
background: linear-gradient(to right, var(--nucleus-accent), var(--nucleus-accent2));
+
}
+
</style>
+46
src/routes/[...catchall]/+page.ts
···
···
+
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 const prerender = false;
+
+
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 {
+
history.replaceState(null, '', '/');
+
} 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);
+
};
+4 -1
svelte.config.js
···
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
-
adapter: adapter(),
alias: {
$lib: 'src/lib',
'$lib/*': 'src/lib/*',
···
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
+
adapter: adapter({
+
fallback: 'index.html',
+
precompress: true
+
}),
alias: {
$lib: 'src/lib',
'$lib/*': 'src/lib/*',