this repo has no description
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor board management and improve caching mechanisms

- Updated EditButton to use agent's assertDid for setting boards.
- Simplified Feed component by removing unused imports and props.
- Added PLC_DIRECTORY constant for external service URL.
- Enhanced useBoardItems and useBoards hooks to accept optional DID parameter for better flexibility.
- Introduced getAllPosts utility function for batch fetching posts.
- Modified BoardItem schema to make createdAt optional.
- Refactored boards store to support multi-DID management and improved state handling.
- Removed custom map storage utility as it's no longer needed.
- Added InfiniteScrollWrapper component for better handling of infinite scrolling.
- Implemented Progress component for visual feedback during loading states.
- Created useBoardPosts hook for managing board-specific post fetching and caching.
- Developed did store for caching DID documents with expiration policies.
- Established posts store for managing board posts with caching and pagination.
- Implemented utility functions for resolving PDS agents based on DID documents.

Turtlepaw 48069f18 7a195099

+1541 -187
+8
.idea/.gitignore
··· 1 + # Default ignored files 2 + /shelf/ 3 + /workspace.xml 4 + # Editor-based HTTP Client requests 5 + /httpRequests/ 6 + # Datasource local storage ignored files 7 + /dataSources/ 8 + /dataSources.local.xml
+6
.idea/inspectionProfiles/Project_Default.xml
··· 1 + <component name="InspectionProjectProfileManager"> 2 + <profile version="1.0"> 3 + <option name="myName" value="Project Default" /> 4 + <inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" /> 5 + </profile> 6 + </component>
+8
.idea/modules.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <project version="4"> 3 + <component name="ProjectModuleManager"> 4 + <modules> 5 + <module fileurl="file://$PROJECT_DIR$/.idea/scribble.iml" filepath="$PROJECT_DIR$/.idea/scribble.iml" /> 6 + </modules> 7 + </component> 8 + </project>
+12
.idea/scribble.iml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <module type="WEB_MODULE" version="4"> 3 + <component name="NewModuleRootManager"> 4 + <content url="file://$MODULE_DIR$"> 5 + <excludeFolder url="file://$MODULE_DIR$/.tmp" /> 6 + <excludeFolder url="file://$MODULE_DIR$/temp" /> 7 + <excludeFolder url="file://$MODULE_DIR$/tmp" /> 8 + </content> 9 + <orderEntry type="inheritedJdk" /> 10 + <orderEntry type="sourceFolder" forTests="false" /> 11 + </component> 12 + </module>
+6
.idea/vcs.xml
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <project version="4"> 3 + <component name="VcsDirectoryMappings"> 4 + <mapping directory="" vcs="Git" /> 5 + </component> 6 + </project>
+125
bun.lock
··· 4 4 "": { 5 5 "name": "scribble", 6 6 "dependencies": { 7 + "@atcute/did-plc": "^0.1.6", 8 + "@atcute/identity-resolver": "^1.1.3", 7 9 "@atproto/api": "^0.15.27", 10 + "@atproto/identity": "^0.4.8", 8 11 "@atproto/oauth-client-browser": "^0.3.27", 9 12 "@cloudflare/next-on-pages": "^1.13.13", 13 + "@did-plc/lib": "^0.0.4", 10 14 "@radix-ui/react-avatar": "^1.1.10", 11 15 "@radix-ui/react-dialog": "^1.1.14", 12 16 "@radix-ui/react-dropdown-menu": "^2.1.15", 13 17 "@radix-ui/react-popover": "^1.1.14", 18 + "@radix-ui/react-progress": "^1.1.7", 14 19 "@radix-ui/react-scroll-area": "^1.2.9", 15 20 "@radix-ui/react-slot": "^1.2.3", 16 21 "@radix-ui/react-tabs": "^1.1.12", ··· 48 53 49 54 "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], 50 55 56 + "@atcute/cbor": ["@atcute/cbor@2.2.5", "", { "dependencies": { "@atcute/cid": "^2.2.3", "@atcute/multibase": "^1.1.4", "@atcute/uint8array": "^1.0.3" } }, "sha512-sBT8+6qau0mC3kwgmjl+nzqGn02xsE9b+kSgXm4/BRd9w8fwdRQYwcC9ApDlfaojrljJfcEkimppl/IcPOF3CA=="], 57 + 58 + "@atcute/cid": ["@atcute/cid@2.2.3", "", { "dependencies": { "@atcute/multibase": "^1.1.4", "@atcute/uint8array": "^1.0.2" } }, "sha512-WEzNSL1EuCVtCQDFYEBIm4dEP6PcMEwi8IYUVIWvT77eO5EjY58F63z5T4qMABxSBM0+L4kqMxypdL1Fzf6LZw=="], 59 + 60 + "@atcute/crypto": ["@atcute/crypto@2.2.3", "", { "dependencies": { "@atcute/multibase": "^1.1.4", "@atcute/uint8array": "^1.0.3", "@noble/secp256k1": "^2.2.3" } }, "sha512-jJI/8WDK6rKvpoUKi0C9Q7pjRRrHGGAagRxnFvpBM5ycZT9eABz7p309LmRKBCWLasmCs/qee8WK4dqOA2e7Dw=="], 61 + 62 + "@atcute/did-plc": ["@atcute/did-plc@0.1.6", "", { "dependencies": { "@atcute/cbor": "^2.2.4", "@atcute/cid": "^2.2.3", "@atcute/crypto": "^2.2.3", "@atcute/identity": "^1.0.3", "@atcute/lexicons": "^1.0.4", "@atcute/multibase": "^1.1.4", "@atcute/uint8array": "^1.0.3", "@badrap/valita": "^0.4.5" } }, "sha512-CaKZpl3UHHUczE4Co7gNi2CR3TPmQgBM0xEkKJJ6Vk4Lu9d+i9GcZQY/VBjmZntfIxHFJgZNdEkMk30lCUVpyw=="], 63 + 64 + "@atcute/identity": ["@atcute/identity@1.0.3", "", { "dependencies": { "@atcute/lexicons": "^1.0.4", "@badrap/valita": "^0.4.5" } }, "sha512-mNMxbKHFGys03A8JXKk0KfMBzdd0vrYMzZZWjpw1nYTs0+ea6bo5S1hwqVUZxHdo1gFHSe/t63jxQIF4yL9aKw=="], 65 + 66 + "@atcute/identity-resolver": ["@atcute/identity-resolver@1.1.3", "", { "dependencies": { "@atcute/lexicons": "^1.0.4", "@atcute/util-fetch": "^1.0.1", "@badrap/valita": "^0.4.4" }, "peerDependencies": { "@atcute/identity": "^1.0.0" } }, "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA=="], 67 + 68 + "@atcute/lexicons": ["@atcute/lexicons@1.1.0", "", { "dependencies": { "esm-env": "^1.2.2" } }, "sha512-LFqwnria78xLYb62Ri/+WwQpUTgZp2DuyolNGIIOV1dpiKhFFFh//nscHMA6IExFLQRqWDs3tTjy7zv0h3sf1Q=="], 69 + 70 + "@atcute/multibase": ["@atcute/multibase@1.1.4", "", { "dependencies": { "@atcute/uint8array": "^1.0.2" } }, "sha512-NUf5AeeSOmuZHGU+4GAaMtISJoG+ZHtW/vUVA4lK/YDt/7LODAW0Fd0NNIIUPVUoW0xJS6zSEIWvwLLuxmEHhA=="], 71 + 72 + "@atcute/uint8array": ["@atcute/uint8array@1.0.3", "", {}, "sha512-M/K+ihiVW8Pl2PFLzaC4E3l4JaZ1IH05Q0AbPWUC4cVHnd/gZ/1kAF5ngdtGvJeDMirHZ2VAy7OmAsPwR/2nlA=="], 73 + 74 + "@atcute/util-fetch": ["@atcute/util-fetch@1.0.1", "", { "dependencies": { "@badrap/valita": "^0.4.2" } }, "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow=="], 75 + 51 76 "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.2.0", "", { "dependencies": { "@atproto-labs/fetch": "0.2.3", "@atproto-labs/pipe": "0.1.1", "@atproto-labs/simple-store": "0.2.0", "@atproto-labs/simple-store-memory": "0.1.3", "@atproto/did": "0.1.5", "zod": "^3.23.8" } }, "sha512-y9GOx2gUETynDKmANnBrU5DTf+u0AwKBJpGns1vDDOYMdLdRCFIeYy3UH+TI8YOkcEazjgF5Q3m+LjwriE1KqQ=="], 52 77 53 78 "@atproto-labs/fetch": ["@atproto-labs/fetch@0.2.3", "", { "dependencies": { "@atproto-labs/pipe": "0.1.1" } }, "sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw=="], ··· 64 89 65 90 "@atproto/api": ["@atproto/api@0.15.27", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/lexicon": "^0.4.12", "@atproto/syntax": "^0.4.0", "@atproto/xrpc": "^0.7.1", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g=="], 66 91 92 + "@atproto/common": ["@atproto/common@0.1.1", "", { "dependencies": { "@ipld/dag-cbor": "^7.0.3", "multiformats": "^9.6.4", "pino": "^8.6.1", "zod": "^3.14.2" } }, "sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg=="], 93 + 67 94 "@atproto/common-web": ["@atproto/common-web@0.4.2", "", { "dependencies": { "graphemer": "^1.4.0", "multiformats": "^9.9.0", "uint8arrays": "3.0.0", "zod": "^3.23.8" } }, "sha512-vrXwGNoFGogodjQvJDxAeP3QbGtawgZute2ed1XdRO0wMixLk3qewtikZm06H259QDJVu6voKC5mubml+WgQUw=="], 68 95 96 + "@atproto/crypto": ["@atproto/crypto@0.4.4", "", { "dependencies": { "@noble/curves": "^1.7.0", "@noble/hashes": "^1.6.1", "uint8arrays": "3.0.0" } }, "sha512-Yq9+crJ7WQl7sxStVpHgie5Z51R05etaK9DLWYG/7bR5T4bhdcIgF6IfklLShtZwLYdVVj+K15s0BqW9a8PSDA=="], 97 + 69 98 "@atproto/did": ["@atproto/did@0.1.5", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-8+1D08QdGE5TF0bB0vV8HLVrVZJeLNITpRTUVEoABNMRaUS7CoYSVb0+JNQDeJIVmqMjOL8dOjvCUDkp3gEaGQ=="], 99 + 100 + "@atproto/identity": ["@atproto/identity@0.4.8", "", { "dependencies": { "@atproto/common-web": "^0.4.2", "@atproto/crypto": "^0.4.4" } }, "sha512-Z0sLnJ87SeNdAifT+rqpgE1Rc3layMMW25gfWNo4u40RGuRODbdfAZlTwBSU2r+Vk45hU+iE+xeQspfednCEnA=="], 70 101 71 102 "@atproto/jwk": ["@atproto/jwk@0.4.0", "", { "dependencies": { "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog=="], 72 103 ··· 86 117 87 118 "@atproto/xrpc": ["@atproto/xrpc@0.7.1", "", { "dependencies": { "@atproto/lexicon": "^0.4.12", "zod": "^3.23.8" } }, "sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g=="], 88 119 120 + "@badrap/valita": ["@badrap/valita@0.4.6", "", {}, "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="], 121 + 89 122 "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], 90 123 91 124 "@cloudflare/next-on-pages": ["@cloudflare/next-on-pages@1.13.13", "", { "dependencies": { "acorn": "^8.8.0", "ast-types": "^0.14.2", "chalk": "^5.2.0", "chokidar": "^3.5.3", "commander": "^11.1.0", "cookie": "^0.5.0", "esbuild": "^0.15.3", "js-yaml": "^4.1.0", "miniflare": "^3.20231218.1", "package-manager-manager": "^0.2.0", "pcre-to-regexp": "^1.1.0", "semver": "^7.5.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240208.0", "vercel": ">=30.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "next-on-pages": "bin/index.js" } }, "sha512-bq63kFz4Hz4lAkYqSWebigbNMr5bPglV9YHfeL+BHIXwysE8psZCx0DIxsSnXP0dNH7Zzat6DS89AxhaxfJQkg=="], ··· 103 136 "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20250718.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg=="], 104 137 105 138 "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], 139 + 140 + "@did-plc/lib": ["@did-plc/lib@0.0.4", "", { "dependencies": { "@atproto/common": "0.1.1", "@atproto/crypto": "0.1.0", "@ipld/dag-cbor": "^7.0.3", "axios": "^1.3.4", "multiformats": "^9.6.4", "uint8arrays": "3.0.0", "zod": "^3.14.2" } }, "sha512-Omeawq3b8G/c/5CtkTtzovSOnWuvIuCI4GTJNrt1AmCskwEQV7zbX5d6km1mjJNbE0gHuQPTVqZxLVqetNbfwA=="], 106 141 107 142 "@edge-runtime/format": ["@edge-runtime/format@2.2.1", "", {}, "sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g=="], 108 143 ··· 250 285 251 286 "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g=="], 252 287 288 + "@ipld/dag-cbor": ["@ipld/dag-cbor@7.0.3", "", { "dependencies": { "cborg": "^1.6.0", "multiformats": "^9.5.4" } }, "sha512-1VVh2huHsuohdXC1bGJNE8WR72slZ9XE2T3wbBBq31dm7ZBatmKLLxrB+XAqafxfRFjv08RZmj/W/ZqaM13AuA=="], 289 + 253 290 "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], 254 291 255 292 "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], ··· 286 323 287 324 "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-SE5pYNbn/xZKMy1RE3pAs+4xD32OI4rY6mzJa4XUkp/ItZY+OMjIgilskmErt8ls/fVJ+Ihopi2QIeW6O3TrMw=="], 288 325 326 + "@noble/curves": ["@noble/curves@1.9.6", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-GIKz/j99FRthB8icyJQA51E8Uk5hXmdyThjgQXRKiv9h0zeRlzSCLIzFw6K1LotZ3XuB7yzlf76qk7uBmTdFqA=="], 327 + 328 + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], 329 + 330 + "@noble/secp256k1": ["@noble/secp256k1@2.3.0", "", {}, "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="], 331 + 289 332 "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], 290 333 291 334 "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], ··· 341 384 "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="], 342 385 343 386 "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], 387 + 388 + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], 344 389 345 390 "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="], 346 391 ··· 534 579 535 580 "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], 536 581 582 + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], 583 + 537 584 "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], 538 585 539 586 "acorn-import-attributes": ["acorn-import-attributes@1.9.5", "", { "peerDependencies": { "acorn": "^8" } }, "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ=="], ··· 592 639 593 640 "async-sema": ["async-sema@3.1.1", "", {}, "sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg=="], 594 641 642 + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], 643 + 644 + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], 645 + 595 646 "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], 596 647 597 648 "await-lock": ["await-lock@2.2.2", "", {}, "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="], 598 649 599 650 "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], 600 651 652 + "axios": ["axios@1.11.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA=="], 653 + 601 654 "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], 602 655 603 656 "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 604 657 658 + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], 659 + 660 + "big-integer": ["big-integer@1.6.52", "", {}, "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg=="], 661 + 605 662 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], 606 663 607 664 "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], ··· 611 668 "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], 612 669 613 670 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 671 + 672 + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], 614 673 615 674 "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], 616 675 ··· 626 685 627 686 "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], 628 687 688 + "cborg": ["cborg@1.10.2", "", { "bin": { "cborg": "cli.js" } }, "sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug=="], 689 + 629 690 "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], 630 691 631 692 "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], ··· 651 712 "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], 652 713 653 714 "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 715 + 716 + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], 654 717 655 718 "commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], 656 719 ··· 690 753 691 754 "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], 692 755 756 + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], 757 + 693 758 "depd": ["depd@1.1.2", "", {}, "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ=="], 694 759 695 760 "detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="], ··· 798 863 799 864 "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], 800 865 866 + "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], 867 + 801 868 "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], 802 869 803 870 "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], ··· 811 878 "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 812 879 813 880 "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], 881 + 882 + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], 883 + 884 + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], 814 885 815 886 "events-intercept": ["events-intercept@2.0.0", "", {}, "sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q=="], 816 887 ··· 826 897 827 898 "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 828 899 900 + "fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="], 901 + 829 902 "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], 830 903 831 904 "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], ··· 844 917 845 918 "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], 846 919 920 + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], 921 + 847 922 "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 848 923 849 924 "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], 925 + 926 + "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], 850 927 851 928 "framer-motion": ["framer-motion@12.23.11", "", { "dependencies": { "motion-dom": "^12.23.9", "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-VzNi+exyI3bn7Pzvz1Fjap1VO9gQu8mxrsSsNamMidsZ8AA8W2kQsR+YQOciEUbMtkKAWIbPHPttfn5e9jqqJQ=="], 852 929 ··· 911 988 "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], 912 989 913 990 "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], 991 + 992 + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], 914 993 915 994 "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 916 995 ··· 1072 1151 1073 1152 "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], 1074 1153 1154 + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], 1155 + 1156 + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], 1157 + 1075 1158 "miniflare": ["miniflare@3.20250718.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250718.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-PlSVKw4k4EDO2JUwW0INtwCDi3BIdHQOeFM3BfV9zU90fwuRJFBhqvbIZdDibEnXHX1cFmSpxzYhPEUyDEACMQ=="], 1076 1159 1077 1160 "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], ··· 1134 1217 1135 1218 "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], 1136 1219 1220 + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], 1221 + 1137 1222 "once": ["once@1.3.3", "", { "dependencies": { "wrappy": "1" } }, "sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w=="], 1223 + 1224 + "one-webcrypto": ["one-webcrypto@1.0.3", "", {}, "sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q=="], 1138 1225 1139 1226 "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 1140 1227 ··· 1180 1267 1181 1268 "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 1182 1269 1270 + "pino": ["pino@8.21.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^1.2.0", "pino-std-serializers": "^6.0.0", "process-warning": "^3.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^3.7.0", "thread-stream": "^2.6.0" }, "bin": { "pino": "bin.js" } }, "sha512-ip4qdzjkAyDDZklUaZkcRFb2iA118H9SgRh8yzTkSQK8HilsOJF7rSY8HoW5+I0M46AZgX/pxbprf2vvzQCE0Q=="], 1271 + 1272 + "pino-abstract-transport": ["pino-abstract-transport@1.2.0", "", { "dependencies": { "readable-stream": "^4.0.0", "split2": "^4.0.0" } }, "sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q=="], 1273 + 1274 + "pino-std-serializers": ["pino-std-serializers@6.2.2", "", {}, "sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA=="], 1275 + 1183 1276 "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 1184 1277 1185 1278 "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], ··· 1190 1283 1191 1284 "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], 1192 1285 1286 + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], 1287 + 1288 + "process-warning": ["process-warning@3.0.0", "", {}, "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ=="], 1289 + 1193 1290 "promisepipe": ["promisepipe@3.0.0", "", {}, "sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA=="], 1194 1291 1195 1292 "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], 1196 1293 1294 + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], 1295 + 1197 1296 "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 1198 1297 1199 1298 "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], 1299 + 1300 + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], 1200 1301 1201 1302 "raw-body": ["raw-body@2.4.1", "", { "dependencies": { "bytes": "3.1.0", "http-errors": "1.7.3", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA=="], 1202 1303 ··· 1214 1315 1215 1316 "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], 1216 1317 1318 + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], 1319 + 1217 1320 "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], 1321 + 1322 + "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], 1218 1323 1219 1324 "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], 1220 1325 ··· 1238 1343 1239 1344 "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], 1240 1345 1346 + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 1347 + 1241 1348 "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], 1242 1349 1243 1350 "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], 1351 + 1352 + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], 1244 1353 1245 1354 "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 1246 1355 ··· 1276 1385 1277 1386 "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 1278 1387 1388 + "sonic-boom": ["sonic-boom@3.8.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-y4Z8LCDBuum+PBP3lSV7RHrXscqksve/bi0as7mhwVnBW+/wUqKT/2Kb7um8yqcFy0duYbbPxzt89Zy2nOCaxg=="], 1389 + 1279 1390 "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], 1280 1391 1281 1392 "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 1282 1393 1283 1394 "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], 1395 + 1396 + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 1284 1397 1285 1398 "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], 1286 1399 ··· 1314 1427 1315 1428 "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], 1316 1429 1430 + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 1431 + 1317 1432 "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], 1318 1433 1319 1434 "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], ··· 1335 1450 "tapable": ["tapable@2.2.2", "", {}, "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg=="], 1336 1451 1337 1452 "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], 1453 + 1454 + "thread-stream": ["thread-stream@2.7.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw=="], 1338 1455 1339 1456 "throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="], 1340 1457 ··· 1472 1589 1473 1590 "@atproto/api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1474 1591 1592 + "@atproto/common/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1593 + 1475 1594 "@atproto/common-web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1476 1595 1477 1596 "@atproto/did/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], ··· 1493 1612 "@cloudflare/next-on-pages/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], 1494 1613 1495 1614 "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], 1615 + 1616 + "@did-plc/lib/@atproto/crypto": ["@atproto/crypto@0.1.0", "", { "dependencies": { "@noble/secp256k1": "^1.7.0", "big-integer": "^1.6.51", "multiformats": "^9.6.4", "one-webcrypto": "^1.0.3", "uint8arrays": "3.0.0" } }, "sha512-9xgFEPtsCiJEPt9o3HtJT30IdFTGw5cQRSJVIy5CFhqBA4vDLcdXiRDLCjkzHEVbtNCsHUW6CrlfOgbeLPcmcg=="], 1617 + 1618 + "@did-plc/lib/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 1496 1619 1497 1620 "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], 1498 1621 ··· 1619 1742 "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], 1620 1743 1621 1744 "youch/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], 1745 + 1746 + "@did-plc/lib/@atproto/crypto/@noble/secp256k1": ["@noble/secp256k1@1.7.2", "", {}, "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ=="], 1622 1747 1623 1748 "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="], 1624 1749
+5
package.json
··· 12 12 "deploy": "npm run pages:build && wrangler pages deploy" 13 13 }, 14 14 "dependencies": { 15 + "@atcute/did-plc": "^0.1.6", 16 + "@atcute/identity-resolver": "^1.1.3", 15 17 "@atproto/api": "^0.15.27", 18 + "@atproto/identity": "^0.4.8", 16 19 "@atproto/oauth-client-browser": "^0.3.27", 17 20 "@cloudflare/next-on-pages": "^1.13.13", 21 + "@did-plc/lib": "^0.0.4", 18 22 "@radix-ui/react-avatar": "^1.1.10", 19 23 "@radix-ui/react-dialog": "^1.1.14", 20 24 "@radix-ui/react-dropdown-menu": "^2.1.15", 21 25 "@radix-ui/react-popover": "^1.1.14", 26 + "@radix-ui/react-progress": "^1.1.7", 22 27 "@radix-ui/react-scroll-area": "^1.2.9", 23 28 "@radix-ui/react-slot": "^1.2.3", 24 29 "@radix-ui/react-tabs": "^1.1.12",
+292 -82
src/app/board/[did]/[rkey]/page.tsx
··· 1 1 "use client"; 2 + 2 3 import { Feed } from "@/components/Feed"; 3 - import { LoaderCircle } from "lucide-react"; 4 - import { useBoardItemsStore } from "@/lib/stores/boardItems"; 5 - import { useBoardsStore } from "@/lib/stores/boards"; 4 + import { GitFork, LoaderCircle } from "lucide-react"; 5 + import { BoardItem, useBoardItemsStore } from "@/lib/stores/boardItems"; 6 + import { Board, useBoardsStore } from "@/lib/stores/boards"; 6 7 import { useCurrentBoard } from "@/lib/stores/useCurrentBoard"; 7 8 import { useAuth } from "@/lib/hooks/useAuth"; 8 9 import { AtUri } from "@atproto/api"; 9 10 import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 10 - import { useParams } from "next/navigation"; 11 - import { useEffect, useMemo, useState } from "react"; 11 + import { useParams, useRouter } from "next/navigation"; 12 + import { useEffect, useMemo, useCallback, useState } from "react"; 12 13 import { EditButton } from "@/components/EditButton"; 13 14 import { paramAsString } from "@/lib/utils/params"; 15 + import { Button } from "@/components/ui/button"; 16 + import { toast } from "sonner"; 17 + import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 18 + import { useBoards } from "@/lib/hooks/useBoards"; 19 + import { useBoardItems } from "@/lib/hooks/useBoardItems"; 20 + import { useBoardPosts } from "@/lib/hooks/posts"; 21 + import { InfiniteScrollWrapper } from "@/components/InfiniteScrollWrapper"; 22 + import { decode } from "punycode"; 23 + import { getAllRecords } from "@/lib/records"; 24 + import { getPdsAgent } from "@/lib/utils/pds"; 25 + import { de } from "zod/v4/locales"; 26 + import { useDidStore } from "@/lib/stores/did"; 14 27 15 28 export const runtime = "edge"; 16 29 30 + type BoardPageParams = { 31 + did: string; 32 + rkey: string; 33 + }; 34 + 35 + const POSTS_PER_PAGE = 25; 36 + 17 37 export default function BoardPage() { 18 - const { did, rkey } = useParams(); 38 + const { did, rkey } = useParams<BoardPageParams>(); 19 39 const { agent } = useAuth(); 40 + const router = useRouter(); 41 + const [isForkingBoard, setIsForkingBoard] = useState(false); 20 42 21 - useCurrentBoard.getState().setCurrentBoard(rkey?.toString() ?? null); 22 - const { boards, isLoading: isBoardsLoading } = useBoardsStore(); 43 + // Parse and validate params 44 + const { decodedDid, isValidParams } = useMemo(() => { 45 + if (!did || !rkey) return { decodedDid: null, isValidParams: false }; 46 + 47 + try { 48 + return { 49 + decodedDid: decodeURIComponent(paramAsString(did)), 50 + isValidParams: true, 51 + }; 52 + } catch { 53 + return { decodedDid: null, isValidParams: false }; 54 + } 55 + }, [did, rkey]); 56 + 57 + // Set current board 58 + useEffect(() => { 59 + useCurrentBoard.getState().setCurrentBoard(rkey?.toString() ?? null); 60 + }, [rkey]); 61 + 62 + // Load board data 63 + const {} = useBoardItems(isValidParams ? decodedDid : null); 64 + const { isLoading: isBoardsLoading } = useBoards( 65 + isValidParams ? decodedDid : null 66 + ); 67 + 68 + // Get stores 69 + const { boards } = useBoardsStore(); 23 70 const { boardItems: items, isLoading: isItemsLoading } = useBoardItemsStore(); 24 71 25 - const [posts, setPosts] = useState<[number, PostView][]>([]); 26 - const [loading, setLoading] = useState(true); 72 + // Get current board and items 73 + const { board, itemsInBoard, cacheKey } = useMemo(() => { 74 + if (!isValidParams || !decodedDid || !rkey) { 75 + return { board: null, itemsInBoard: [], cacheKey: "" }; 76 + } 77 + 78 + const currentBoard = boards[decodedDid]?.[paramAsString(rkey)] || null; 79 + const currentItems = Array.from(items.entries()).filter(([, item]) => { 80 + const listUri = new AtUri(item.list); 81 + return ( 82 + listUri.rkey === paramAsString(rkey) && listUri.host === decodedDid 83 + ); 84 + }); 27 85 28 - const itemsInBoard = useMemo(() => { 29 - if (!rkey) return []; 30 - return Array.from(items.entries()).filter( 31 - ([, item]) => new AtUri(item.list).rkey === paramAsString(rkey) 32 - ); 33 - }, [items, rkey]); 86 + const key = `${decodedDid}:${paramAsString(rkey)}`; 87 + 88 + return { board: currentBoard, itemsInBoard: currentItems, cacheKey: key }; 89 + }, [boards, items, decodedDid, rkey, isValidParams]); 34 90 35 - const board = useMemo( 36 - () => (rkey ? boards.get(paramAsString(rkey)) : null), 37 - [boards, rkey] 38 - ); 91 + // Use posts hook with pagination 92 + const { 93 + posts, 94 + isLoading: isPostsLoading, 95 + hasMore, 96 + loadMore, 97 + isLoadingMore, 98 + refresh, 99 + error, 100 + totalPages, 101 + } = useBoardPosts({ 102 + itemsInBoard, 103 + agent, 104 + pageSize: POSTS_PER_PAGE, 105 + enabled: isValidParams && !isItemsLoading && itemsInBoard.length > 0, 106 + boardKey: cacheKey, 107 + }); 39 108 40 - // Initial fetch 41 - useEffect(() => { 42 - if (!agent || !rkey || !did || itemsInBoard.length === 0) { 43 - setLoading(false); 109 + // Fork board handler 110 + const handleForkBoard = useCallback(async () => { 111 + if (!agent || !board || !decodedDid || isForkingBoard) { 112 + if (!isForkingBoard) toast("Unable to fork board"); 44 113 return; 45 114 } 46 115 47 - let cancelled = false; 116 + setIsForkingBoard(true); 117 + 118 + try { 119 + toast("Forking board...", { duration: Infinity, dismissible: false }); 120 + 121 + // First, fetch ALL board items from the API, not just loaded ones 122 + const boardUri = AtUri.make( 123 + decodedDid, 124 + LIST_COLLECTION, 125 + paramAsString(rkey) 126 + ); 127 + 128 + const tempAgent = await getPdsAgent( 129 + decodedDid, 130 + useDidStore.getState(), 131 + agent 132 + ); 48 133 49 - const fetchPosts = async () => { 50 - try { 51 - const uris = itemsInBoard.map(([, item]) => 52 - AtUri.make(item.url.split("?")[0]).toString() 53 - ); 134 + // Query all list items for this board 135 + const allItemsResponse = await getAllRecords({ 136 + repo: decodedDid, 137 + collection: LIST_ITEM_COLLECTION, 138 + limit: 100, 139 + agent: tempAgent, 140 + }); 54 141 55 - const response = await agent.getPosts({ uris }); 56 - const skeets = response?.data.posts || []; 142 + // Filter items that belong to this specific board 143 + const allBoardItems = allItemsResponse.filter((record) => { 144 + const value = BoardItem.safeParse(record.value); 145 + if (!value.success) return false; 146 + const item = value.data; 147 + return item.list === boardUri.toString(); 148 + }); 57 149 58 - if (!cancelled) { 59 - const newPosts: [number, PostView][] = skeets.map((skeet) => { 60 - const uri = new AtUri(skeet.uri); 61 - const index = Number(uri.searchParams.get("image")) || 0; 62 - return [index, skeet] as [number, PostView]; 63 - }); 150 + // Create list record 151 + const listRes = await agent.com.atproto.repo.createRecord({ 152 + collection: LIST_COLLECTION, 153 + record: board, 154 + repo: agent.assertDid, 155 + }); 64 156 65 - setPosts(newPosts); 66 - setLoading(false); 67 - } 68 - } catch (error) { 69 - console.error("Error fetching posts:", error); 70 - if (!cancelled) setLoading(false); 157 + if (!listRes.success) { 158 + toast("Failed to create list"); 159 + return; 71 160 } 72 - }; 73 161 74 - fetchPosts(); 75 - return () => { 76 - cancelled = true; 77 - }; 78 - }, [agent, did, rkey, itemsInBoard]); 162 + const { setBoardItem } = useBoardItemsStore.getState(); 163 + const { setBoard } = useBoardsStore.getState(); 164 + const newRkey = new AtUri(listRes.data.uri).rkey; 165 + const newBoardUri = listRes.data.uri; 79 166 80 - if (!rkey || !did) { 81 - return ( 82 - <div className="min-h-screen flex items-center justify-center px-4"> 83 - <p className="text-red-500 dark:text-red-400">No rkey or did</p> 84 - </div> 85 - ); 86 - } 167 + setBoard(agent.assertDid, newRkey, board); 168 + 169 + // Create list items for ALL items, not just loaded ones 170 + const itemPromises = allBoardItems.map(async (record) => { 171 + const originalItem = record.value as BoardItem; 172 + 173 + // Update the list reference to point to the new forked board 174 + const newItem = { 175 + ...originalItem, 176 + list: newBoardUri, 177 + }; 178 + 179 + const listItemRes = await agent.com.atproto.repo.createRecord({ 180 + collection: LIST_ITEM_COLLECTION, 181 + record: newItem, 182 + repo: agent.assertDid, 183 + }); 184 + 185 + if (listItemRes.success) { 186 + const itemResult = BoardItem.safeParse(listItemRes.data); 187 + if (itemResult.success) { 188 + setBoardItem(new AtUri(listItemRes.data.uri).rkey, itemResult.data); 189 + } 190 + } else { 191 + console.warn("List item failed to be created", originalItem); 192 + } 193 + }); 194 + 195 + await Promise.allSettled(itemPromises); 196 + 197 + toast.dismiss(); // Dismiss the forking toast 198 + toast(`Board forked successfully with ${allBoardItems.length} items!`); 87 199 88 - if (isItemsLoading || isBoardsLoading || loading) { 89 - return ( 90 - <div className="min-h-screen flex items-center justify-center px-4"> 91 - <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 92 - </div> 93 - ); 200 + // Redirect to the new forked board 201 + const encodedDid = encodeURIComponent(agent.assertDid); 202 + router.push(`/board/${encodedDid}/${newRkey}`); 203 + } catch (error) { 204 + console.error("Error forking board:", error); 205 + toast.dismiss(); 206 + toast("Failed to fork board"); 207 + } finally { 208 + setIsForkingBoard(false); 209 + } 210 + }, [agent, board, decodedDid, rkey, isForkingBoard, router]); 211 + 212 + // Loading states 213 + if (!isValidParams) { 214 + return <ErrorState message="Invalid board parameters" />; 94 215 } 95 216 96 - if (itemsInBoard.length === 0) { 97 - return ( 98 - <div className="min-h-screen flex items-center justify-center px-4"> 99 - <p className="text-black/70 dark:text-white/70">No items found</p> 100 - </div> 101 - ); 217 + if (isItemsLoading || isBoardsLoading) { 218 + return <LoadingState />; 102 219 } 103 220 104 221 if (!board) { 105 - return ( 106 - <div className="min-h-screen flex items-center justify-center px-4"> 107 - <p className="text-black/70 dark:text-white/70">No board found</p> 108 - </div> 109 - ); 222 + return <EmptyState message="Board not found" />; 110 223 } 224 + 225 + const canEdit = agent?.did == decodedDid; 226 + const isLoading = isPostsLoading || isLoadingMore; 111 227 112 228 return ( 113 229 <div className="px-5"> 230 + <BoardHeader 231 + board={board} 232 + canEdit={canEdit} 233 + rkey={paramAsString(rkey)} 234 + onFork={handleForkBoard} 235 + isForkingBoard={isForkingBoard} 236 + /> 237 + {error && ( 238 + <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"> 239 + <p className="text-red-600 dark:text-red-400 text-sm"> 240 + Error loading posts: {error.message} 241 + </p> 242 + <button 243 + onClick={refresh} 244 + className="mt-2 text-red-600 dark:text-red-400 hover:underline text-sm" 245 + > 246 + Try again 247 + </button> 248 + </div> 249 + )} 250 + {itemsInBoard.length === 0 && ( 251 + <EmptyState message="No items in this board" /> 252 + )} 253 + {itemsInBoard.length > 0 && ( 254 + <InfiniteScrollWrapper 255 + hasMore={hasMore} 256 + onLoadMore={loadMore} 257 + isLoadingMore={isLoadingMore} 258 + showEndMessage={totalPages > 1} 259 + > 260 + <Feed 261 + feed={posts} 262 + isLoading={isLoading && posts.length === 0} 263 + showUnsaveButton={canEdit} 264 + /> 265 + </InfiniteScrollWrapper> 266 + )} 267 + </div> 268 + ); 269 + } 270 + 271 + // Extracted components for better maintainability 272 + function BoardHeader({ 273 + board, 274 + canEdit, 275 + rkey, 276 + onFork, 277 + isForkingBoard, 278 + }: { 279 + board: Board; 280 + canEdit: boolean; 281 + rkey: string; 282 + onFork: () => void; 283 + isForkingBoard: boolean; 284 + }) { 285 + return ( 286 + <div className="flex flex-row mb-5 justify-between"> 114 287 <div className="flex flex-row"> 115 - <div className="mb-5 ml-2"> 116 - <h2 className="font-bold text-xl">{board.name}</h2> 288 + <div className="ml-2"> 289 + <div className="flex items-center gap-2"> 290 + <h2 className="font-bold text-xl">{board.name}</h2> 291 + </div> 117 292 <p className="text-black/80 dark:text-white/80"> 118 293 {board.description} 119 294 </p> 120 295 </div> 121 - <EditButton board={board} rkey={paramAsString(rkey)} className="ml-3" /> 296 + {canEdit && <EditButton board={board} rkey={rkey} className="ml-3" />} 122 297 </div> 123 - <Feed 124 - feed={posts} 125 - showUnsaveButton={true} 126 - onUnsave={(imageUrl, index) => console.log("Unsave", imageUrl)} 127 - /> 298 + {!canEdit && ( 299 + <div> 300 + <Button 301 + className="gap-2 cursor-pointer" 302 + onClick={onFork} 303 + disabled={isForkingBoard} 304 + > 305 + {isForkingBoard ? ( 306 + <LoaderCircle className="animate-spin w-4 h-4" /> 307 + ) : ( 308 + <GitFork /> 309 + )} 310 + {isForkingBoard ? "Forking..." : "Fork"} 311 + </Button> 312 + </div> 313 + )} 314 + </div> 315 + ); 316 + } 317 + 318 + function LoadingState() { 319 + return ( 320 + <div className="min-h-screen flex items-center justify-center px-4"> 321 + <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 322 + </div> 323 + ); 324 + } 325 + 326 + function EmptyState({ message }: { message: string }) { 327 + return ( 328 + <div className="min-h-screen flex items-center justify-center px-4"> 329 + <p className="text-black/70 dark:text-white/70">{message}</p> 330 + </div> 331 + ); 332 + } 333 + 334 + function ErrorState({ message }: { message: string }) { 335 + return ( 336 + <div className="min-h-screen flex items-center justify-center px-4"> 337 + <p className="text-red-500 dark:text-red-400">{message}</p> 128 338 </div> 129 339 ); 130 340 }
+8 -5
src/app/boards/page.tsx
··· 17 17 } 18 18 19 19 export default function BoardsPage() { 20 - const { boards, isLoading } = useBoardsStore(); 20 + const { boards, isLoading, getBoards } = useBoardsStore(); 21 21 const { agent } = useAuth(); 22 + 23 + if (!agent) return <div>Not logged in</div>; 24 + const boardsFromDid = getBoards(agent.assertDid); 22 25 23 26 if (isLoading) 24 27 return ( ··· 26 29 <LoaderCircle className="animate-spin text-black/70 dark:text-white/70 w-8 h-8" /> 27 30 </div> 28 31 ); 29 - if (boards.size <= 0) 32 + if (!boardsFromDid || Object.entries(boardsFromDid).length <= 0) 30 33 return ( 31 34 <div className="min-h-screen flex items-center justify-center px-4"> 32 35 <p className="text-black/70 dark:text-white/70">No boards found</p> ··· 38 41 <div className="w-full max-w-4xl"> 39 42 <h1 className="font-medium text-lg mb-4">My Boards</h1> 40 43 <div className="grid grid-cols-1 md:grid-cols-4 gap-3"> 41 - {Array.from(boards.entries()).map(([key, it]) => ( 44 + {Array.from(Object.entries(boardsFromDid)).map(([key, it]) => ( 42 45 <Link 43 46 href={`/board/${agent?.did ?? "unknown"}/${key}`} 44 47 key={key} 45 48 className="h-full" 46 49 > 47 50 <motion.div 48 - initial={{ opacity: 0, y: 2 }} 49 - animate={{ opacity: 1, y: 0 }} 51 + initial={{ opacity: 0, y: 2, filter: "blur(4px)" }} 52 + animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} 50 53 transition={{ duration: 0.3, ease: "easeOut" }} 51 54 whileTap={{ scale: 0.95 }} 52 55 className="flex flex-col h-full bg-black/10 dark:bg-white/3 p-4 rounded-lg hover:bg-black/15 dark:hover:bg-white/5 transition-colors"
+78 -49
src/components/DeleteButton.tsx
··· 23 23 import clsx from "clsx"; 24 24 import { Input } from "./ui/input"; 25 25 import { Textarea } from "./ui/textarea"; 26 + import { Progress } from "./ui/progress"; 26 27 27 28 export function DeleteButton({ board, rkey }: { board: Board; rkey: string }) { 28 29 const { agent } = useAuth(); ··· 32 33 const [description, setDescription] = useState(board.description); 33 34 const { setBoard, removeBoard } = useBoardsStore(); 34 35 const { boardItems } = useBoardItemsStore(); 36 + const [progress, setProgress] = useState(0); 37 + const [totalItems, setTotalItems] = useState(0); 35 38 36 39 if (agent == null) return <div>not logged in :(</div>; 37 40 return ( ··· 63 66 Looked like it was a pretty good board you had going there. 64 67 </DialogDescription> 65 68 </DialogHeader> 69 + 70 + {isLoading && ( 71 + <div className="space-y-2 my-4"> 72 + <div className="text-sm text-muted-foreground"> 73 + Deleting items... ({progress}/{totalItems}) 74 + </div> 75 + <Progress value={(progress / Math.max(totalItems, 1)) * 100} /> 76 + </div> 77 + )} 78 + 66 79 <DialogFooter> 67 - <DialogClose> 68 - <Button className="cursor-pointer" variant={"secondary"}> 69 - Cancel 70 - </Button> 71 - </DialogClose> 72 - <Button 73 - onClick={async (e) => { 74 - e.stopPropagation(); // Optional, but safe 80 + {!isLoading && ( 81 + <DialogClose> 82 + <Button className="cursor-pointer" variant={"secondary"}> 83 + Cancel 84 + </Button> 85 + </DialogClose> 86 + )} 75 87 76 - setLoading(true); 77 - try { 78 - const listUri = AtUri.make( 79 - agent.assertDid, 80 - LIST_COLLECTION, 81 - rkey 82 - ); 83 - const items = boardItems 84 - .entries() 85 - .filter((e) => AtUri.make(e[1].list).rkey == listUri.rkey); 88 + {!isLoading ? ( 89 + <Button 90 + onClick={async (e) => { 91 + e.stopPropagation(); 92 + 93 + setLoading(true); 94 + try { 95 + const listUri = AtUri.make( 96 + agent.assertDid, 97 + LIST_COLLECTION, 98 + rkey 99 + ); 100 + const items = boardItems 101 + .entries() 102 + .filter((e) => AtUri.make(e[1].list).rkey == listUri.rkey); 103 + 104 + setTotalItems(items.length + 1); // +1 for the board itself 105 + setProgress(0); 106 + 107 + for (let i = 0; i < items.length; i++) { 108 + const item = items[i]; 109 + const itemDeleteRes = 110 + await agent.com.atproto.repo.deleteRecord({ 111 + repo: agent.assertDid, 112 + collection: LIST_ITEM_COLLECTION, 113 + rkey: item[0], 114 + }); 115 + 116 + if (!itemDeleteRes.success) { 117 + toast(`Failed to delete ${item[0]}`); 118 + } 119 + 120 + setProgress(i + 1); 121 + } 86 122 87 - for (const item of items) { 88 - const itemDeleteRes = 123 + const listDeleteRes = 89 124 await agent.com.atproto.repo.deleteRecord({ 90 125 repo: agent.assertDid, 91 - collection: LIST_ITEM_COLLECTION, 92 - rkey: item[0], 126 + collection: LIST_COLLECTION, 127 + rkey: rkey, 93 128 }); 94 129 95 - if (!itemDeleteRes.success) { 96 - toast(`Failed to delete ${item[0]}`); 97 - } 98 - } 130 + setProgress(totalItems); 99 131 100 - const listDeleteRes = await agent.com.atproto.repo.deleteRecord( 101 - { 102 - repo: agent.assertDid, 103 - collection: LIST_COLLECTION, 104 - rkey: rkey, 132 + if (listDeleteRes.success) { 133 + removeBoard(agent.assertDid, rkey); 134 + toast("Board deleted"); 135 + setOpen(false); 136 + } else { 137 + toast("Failed to delete board"); 105 138 } 106 - ); 107 - 108 - if (listDeleteRes.success) { 109 - removeBoard(rkey); 110 - toast("Board deleted"); 111 - setOpen(false); 112 - } else { 113 - toast("Failed to delete board"); 139 + } finally { 140 + setLoading(false); 114 141 } 115 - } finally { 116 - setLoading(false); 117 - } 118 - }} 119 - disabled={name.length <= 0} 120 - className="cursor-pointer" 121 - > 122 - {isLoading && <LoaderCircle className="animate-spin ml-2" />} 123 - Confirm 124 - </Button> 142 + }} 143 + disabled={name.length <= 0} 144 + className="cursor-pointer" 145 + > 146 + Confirm 147 + </Button> 148 + ) : ( 149 + <Button disabled className="cursor-not-allowed"> 150 + <LoaderCircle className="animate-spin mr-2" /> 151 + Deleting... 152 + </Button> 153 + )} 125 154 </DialogFooter> 126 155 </DialogContent> 127 156 </Dialog>
+1 -1
src/components/EditButton.tsx
··· 115 115 newRecord.success && 116 116 newRecordData.success 117 117 ) { 118 - setBoard(rkey, newRecordData.data); 118 + setBoard(agent.assertDid, rkey, newRecordData.data); 119 119 toast("Board updated"); 120 120 setOpen(false); 121 121 } else {
+3 -16
src/components/Feed.tsx
··· 1 1 "use client"; 2 2 import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 - import { useAuth } from "@/lib/hooks/useAuth"; 4 - import { 5 - AppBskyEmbedImages, 6 - AppBskyFeedDefs, 7 - AppBskyFeedPost, 8 - AtUri, 9 - } from "@atproto/api"; 3 + import { AppBskyEmbedImages, AppBskyFeedPost, AtUri } from "@atproto/api"; 10 4 import { LoaderCircle } from "lucide-react"; 11 - import { useEffect, useRef, useState, useCallback } from "react"; 12 5 import { motion } from "motion/react"; 13 - import { Button } from "@/components/ui/button"; 14 6 import Image from "next/image"; 15 7 import Link from "next/link"; 16 8 import Masonry from "react-masonry-css"; ··· 44 36 45 37 isLoading?: boolean; 46 38 showUnsaveButton?: boolean; 47 - onUnsave?: (imageUrl: string, index: number) => void; 48 39 } 49 40 50 41 function getText(post: PostView) { ··· 64 55 function ImageCard({ 65 56 item, 66 57 showUnsaveButton, 67 - onUnsave, 68 58 index, 69 59 }: { 70 60 item: PostView; 71 61 showUnsaveButton?: boolean; 72 - onUnsave?: (imageUrl: string, index: number) => void; 73 62 index: number; 74 63 }) { 75 64 const image = getImageFromItem(item, index); ··· 80 69 const txt = getText(item); 81 70 82 71 return ( 83 - <div key={item.uri} className="relative group"> 72 + <div className="relative group"> 84 73 {/* Save/Unsave button – top-left */} 85 74 <div className="absolute top-3 left-3 z-30 opacity-0 group-hover:opacity-100 transition-opacity"> 86 75 {ActionButton && <ActionButton image={index} post={item} />} ··· 186 175 feed, 187 176 isLoading = false, 188 177 showUnsaveButton = false, 189 - onUnsave, 190 178 }: FeedProps) { 191 179 return ( 192 180 <> ··· 197 185 > 198 186 {feed?.map(([index, item]) => ( 199 187 <ImageCard 200 - key={item.uri} 188 + key={`${item.uri}-${index}`} 201 189 item={item} 202 190 index={index} 203 191 showUnsaveButton={showUnsaveButton} 204 - onUnsave={onUnsave} 205 192 /> 206 193 ))} 207 194 </Masonry>
+103
src/components/InfiniteScrollWrapper.tsx
··· 1 + import React, { useEffect, useRef, useCallback } from "react"; 2 + import { LoaderCircle } from "lucide-react"; 3 + 4 + interface InfiniteScrollWrapperProps { 5 + children: React.ReactNode; 6 + hasMore?: boolean; 7 + onLoadMore?: () => Promise<void>; 8 + isLoadingMore?: boolean; 9 + className?: string; 10 + loadingMessage?: string; 11 + endMessage?: string; 12 + threshold?: number; 13 + rootMargin?: string; 14 + showEndMessage?: boolean; 15 + } 16 + 17 + export function InfiniteScrollWrapper({ 18 + children, 19 + hasMore = false, 20 + onLoadMore, 21 + isLoadingMore = false, 22 + className = "", 23 + loadingMessage = "Loading more posts...", 24 + endMessage = "No more posts to load", 25 + threshold = 0.1, 26 + rootMargin = "100px", 27 + showEndMessage = true, 28 + }: InfiniteScrollWrapperProps) { 29 + const loadMoreRef = useRef<HTMLDivElement>(null); 30 + const observerRef = useRef<IntersectionObserver | null>(null); 31 + 32 + // Intersection Observer for infinite scroll 33 + const handleObserver = useCallback( 34 + (entries: IntersectionObserverEntry[]) => { 35 + const [target] = entries; 36 + if (target.isIntersecting && hasMore && onLoadMore && !isLoadingMore) { 37 + onLoadMore(); 38 + } 39 + }, 40 + [hasMore, onLoadMore, isLoadingMore] 41 + ); 42 + 43 + useEffect(() => { 44 + const element = loadMoreRef.current; 45 + if (!element || !onLoadMore) return; 46 + 47 + // Disconnect existing observer 48 + if (observerRef.current) { 49 + observerRef.current.disconnect(); 50 + } 51 + 52 + // Create new observer 53 + observerRef.current = new IntersectionObserver(handleObserver, { 54 + threshold, 55 + rootMargin, 56 + }); 57 + 58 + observerRef.current.observe(element); 59 + 60 + return () => { 61 + if (observerRef.current) { 62 + observerRef.current.disconnect(); 63 + } 64 + }; 65 + }, [ 66 + handleObserver, 67 + threshold, 68 + rootMargin, 69 + onLoadMore, 70 + isLoadingMore, 71 + hasMore, 72 + ]); 73 + 74 + return ( 75 + <div className={className}> 76 + {children} 77 + 78 + {/* Infinite scroll trigger and loading states */} 79 + {hasMore && ( 80 + <div 81 + ref={loadMoreRef} 82 + className="flex items-center justify-center py-8" 83 + > 84 + {isLoadingMore ? ( 85 + <div className="flex items-center gap-2 text-black/70 dark:text-white/70"> 86 + <LoaderCircle className="animate-spin w-5 h-5" /> 87 + <span className="text-sm">{loadingMessage}</span> 88 + </div> 89 + ) : ( 90 + <div className="text-gray-400 text-sm">Scroll to load more</div> 91 + )} 92 + </div> 93 + )} 94 + 95 + {/* End of posts indicator */} 96 + {!hasMore && showEndMessage && ( 97 + <div className="flex items-center justify-center py-6"> 98 + <p className="text-gray-400 text-sm">{endMessage}</p> 99 + </div> 100 + )} 101 + </div> 102 + ); 103 + }
+31
src/components/ui/progress.tsx
··· 1 + "use client" 2 + 3 + import * as React from "react" 4 + import * as ProgressPrimitive from "@radix-ui/react-progress" 5 + 6 + import { cn } from "@/lib/utils" 7 + 8 + function Progress({ 9 + className, 10 + value, 11 + ...props 12 + }: React.ComponentProps<typeof ProgressPrimitive.Root>) { 13 + return ( 14 + <ProgressPrimitive.Root 15 + data-slot="progress" 16 + className={cn( 17 + "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", 18 + className 19 + )} 20 + {...props} 21 + > 22 + <ProgressPrimitive.Indicator 23 + data-slot="progress-indicator" 24 + className="bg-primary h-full w-full flex-1 transition-all" 25 + style={{ transform: `translateX(-${100 - (value || 0)}%)` }} 26 + /> 27 + </ProgressPrimitive.Root> 28 + ) 29 + } 30 + 31 + export { Progress }
+1
src/constants.ts
··· 1 1 export const LIST_COLLECTION = "org.scrapboard.list"; 2 2 export const LIST_ITEM_COLLECTION = "org.scrapboard.listitem"; 3 + export const PLC_DIRECTORY = "https://plc.directory";
+293
src/lib/hooks/posts.tsx
··· 1 + import { useState, useEffect, useCallback, useMemo } from "react"; 2 + import { AtUri } from "@atproto/api"; 3 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4 + import { Agent } from "@atproto/api"; 5 + import { getAllPosts } from "@/lib/records"; 6 + import { usePostsStore, BoardPostsData } from "@/lib/stores/posts"; 7 + 8 + import { BoardItem } from "../stores/boardItems"; 9 + 10 + export interface UseBoardPostsOptions { 11 + itemsInBoard: [string, BoardItem][]; // [rkey, BoardItem] 12 + agent: Agent | null; 13 + pageSize: number; 14 + enabled: boolean; 15 + boardKey: string; 16 + } 17 + 18 + export interface UseBoardPostsReturn { 19 + posts: [number, PostView][]; 20 + isLoading: boolean; 21 + isLoadingMore: boolean; 22 + hasMore: boolean; 23 + currentPage: number; 24 + totalPages: number; 25 + loadMore: () => Promise<void>; 26 + refresh: () => Promise<void>; 27 + error: Error | null; 28 + isStale: boolean; 29 + } 30 + 31 + export function useBoardPosts({ 32 + itemsInBoard, 33 + agent, 34 + pageSize, 35 + enabled, 36 + boardKey, 37 + }: UseBoardPostsOptions): UseBoardPostsReturn { 38 + const [currentPage, setCurrentPage] = useState(0); 39 + const [isLoading, setIsLoading] = useState(false); 40 + const [isLoadingMore, setIsLoadingMore] = useState(false); 41 + const [error, setError] = useState<Error | null>(null); 42 + 43 + const { setBoardPosts, appendBoardPosts, checkCache, refreshCache } = 44 + usePostsStore(); 45 + 46 + // Subscribe to the cache entry so component re-renders when it changes 47 + const cacheEntry = usePostsStore((s) => 48 + boardKey ? s.boards.get(boardKey) ?? null : null 49 + ); 50 + 51 + // Check cache status 52 + const cacheResult = useMemo(() => { 53 + return boardKey ? checkCache(boardKey) : null; 54 + }, [boardKey, checkCache]); 55 + 56 + const isStale = cacheResult?.stale ?? false; 57 + const isExpired = cacheResult?.expired ?? false; 58 + 59 + // Get posts from cache for pages 0..currentPage 60 + const posts = useMemo(() => { 61 + if (!boardKey || !cacheEntry) return []; 62 + 63 + const all = cacheEntry.data.posts; 64 + const end = Math.min((currentPage + 1) * pageSize, all.length); 65 + return all 66 + .slice(0, end) 67 + .map(({ post, index }) => [index, post] as [number, PostView]); 68 + }, [boardKey, cacheEntry, currentPage, pageSize]); 69 + 70 + // Calculate pagination info 71 + const totalPages = useMemo(() => { 72 + // Derive from live itemsInBoard length so we don't freeze totalPages at initial fetch 73 + return Math.ceil(itemsInBoard.length / pageSize) || 0; 74 + }, [itemsInBoard.length, pageSize]); 75 + const hasMore = currentPage < totalPages - 1; 76 + 77 + // Create fetch function for refreshCache 78 + const createFetchFunction = useCallback( 79 + (targetPage: number) => { 80 + return async (): Promise<BoardPostsData | null> => { 81 + if (!agent || !boardKey || itemsInBoard.length === 0) { 82 + return null; 83 + } 84 + 85 + try { 86 + // Calculate which items to load for this page 87 + const startIndex = targetPage * pageSize; 88 + const endIndex = Math.min(startIndex + pageSize, itemsInBoard.length); 89 + const pageItems = itemsInBoard.slice(startIndex, endIndex); 90 + 91 + if (pageItems.length === 0) { 92 + return null; 93 + } 94 + 95 + // Extract canonical URIs and dedupe to minimize API calls 96 + const canonicalUris = pageItems.map( 97 + ([, item]) => item.url.split("?")[0] 98 + ); 99 + const uniqueAtUris = Array.from(new Set(canonicalUris)).map( 100 + (u) => new AtUri(u) 101 + ); 102 + 103 + console.log( 104 + `Fetching page ${targetPage}: ${canonicalUris.length} items (${uniqueAtUris.length} unique posts)` 105 + ); 106 + 107 + // Fetch unique posts 108 + const fetchedPosts = await getAllPosts({ 109 + posts: uniqueAtUris, 110 + agent, 111 + }); 112 + 113 + // Build a map for quick lookup 114 + const postByUri = new Map(fetchedPosts.map((p) => [p.uri, p])); 115 + 116 + // Rebuild result preserving duplicates and per-image index from saved board item URL 117 + const postsWithIndex = pageItems 118 + .map(([, item]) => { 119 + const cleanUrl = item.url.split("?")[0]; 120 + const post = postByUri.get(cleanUrl); 121 + if (!post) return null; 122 + 123 + // Extract ?image=<n> from the saved URL (query on at:// string) 124 + let imageIndex = 0; 125 + const qs = item.url.split("?")[1]; 126 + if (qs) { 127 + const sp = new URLSearchParams(qs); 128 + const v = sp.get("image"); 129 + if (v != null) imageIndex = Number(v) || 0; 130 + } 131 + 132 + return { post, index: imageIndex }; 133 + }) 134 + .filter((v): v is { post: PostView; index: number } => v !== null); 135 + 136 + return { 137 + posts: postsWithIndex, 138 + totalItems: itemsInBoard.length, 139 + loadedPages: [targetPage], 140 + }; 141 + } catch (err) { 142 + console.error(`Error fetching page ${targetPage}:`, err); 143 + throw err; 144 + } 145 + }; 146 + }, 147 + [agent, boardKey, itemsInBoard, pageSize] 148 + ); 149 + 150 + // Load posts for a specific page 151 + const loadPage = useCallback( 152 + async (page: number, isInitial = false, force = false) => { 153 + if (!enabled || !agent || !boardKey || itemsInBoard.length === 0) { 154 + return; 155 + } 156 + 157 + // Check if page is already cached and not forcing refresh 158 + const hasPage = cacheEntry?.data.loadedPages.includes(page) ?? false; 159 + if (hasPage && !force && !isInitial) { 160 + return; 161 + } 162 + 163 + const setLoadingState = isInitial ? setIsLoading : setIsLoadingMore; 164 + setLoadingState(true); 165 + setError(null); 166 + 167 + try { 168 + const shouldRefresh = page === 0 && (force || isExpired || !hasPage); 169 + if (shouldRefresh) { 170 + // Page 0 – replace cache via refresh 171 + await refreshCache( 172 + boardKey, 173 + createFetchFunction(page), 174 + cacheResult || undefined 175 + ); 176 + } else { 177 + // Page > 0 – fetch and append 178 + const fetchFunction = createFetchFunction(page); 179 + const result = await fetchFunction(); 180 + 181 + if (result && result.posts.length > 0) { 182 + const postsWithIndex: [number, PostView][] = result.posts.map( 183 + ({ post, index }) => [index, post] 184 + ); 185 + 186 + if (page === 0) { 187 + setBoardPosts( 188 + boardKey, 189 + postsWithIndex, 190 + page, 191 + pageSize, 192 + result.totalItems 193 + ); 194 + } else { 195 + appendBoardPosts(boardKey, postsWithIndex, page); 196 + } 197 + } 198 + } 199 + } catch (err) { 200 + console.error(`Error loading page ${page}:`, err); 201 + setError( 202 + err instanceof Error ? err : new Error("Failed to load posts") 203 + ); 204 + } finally { 205 + setLoadingState(false); 206 + } 207 + }, 208 + [ 209 + enabled, 210 + agent, 211 + boardKey, 212 + itemsInBoard, 213 + pageSize, 214 + cacheEntry?.data.loadedPages, 215 + refreshCache, 216 + createFetchFunction, 217 + setBoardPosts, 218 + appendBoardPosts, 219 + cacheResult, 220 + isExpired, 221 + ] 222 + ); 223 + 224 + // Load more posts (next page) 225 + const loadMore = useCallback(async () => { 226 + const firstPageReady = cacheEntry?.data.loadedPages.includes(0) ?? false; 227 + if (isLoadingMore || !hasMore || isLoading || !firstPageReady) return; 228 + 229 + const nextPage = currentPage + 1; 230 + await loadPage(nextPage); 231 + setCurrentPage(nextPage); 232 + }, [ 233 + currentPage, 234 + hasMore, 235 + isLoadingMore, 236 + isLoading, 237 + cacheEntry?.data.loadedPages, 238 + loadPage, 239 + ]); 240 + 241 + // Refresh all data 242 + const refresh = useCallback(async () => { 243 + if (!boardKey) return; 244 + 245 + setCurrentPage(0); 246 + setError(null); 247 + await loadPage(0, true, true); // Force refresh 248 + }, [boardKey, loadPage]); 249 + 250 + // Load initial page when enabled 251 + useEffect(() => { 252 + if (enabled && boardKey) { 253 + const needsLoad = 254 + !cacheEntry || 255 + isExpired || 256 + !(cacheEntry.data.loadedPages || []).includes(0); 257 + if (needsLoad) { 258 + setCurrentPage(0); 259 + loadPage(0, true, isExpired); 260 + } 261 + } 262 + }, [enabled, boardKey, cacheEntry, isExpired, loadPage]); 263 + 264 + // Auto-refresh stale data in background 265 + useEffect(() => { 266 + if (enabled && boardKey && isStale && !isLoading && !isLoadingMore) { 267 + // Background refresh for stale data 268 + loadPage(0, false, true); 269 + } 270 + }, [enabled, boardKey, isStale, isLoading, isLoadingMore, loadPage]); 271 + 272 + // Update loading state 273 + useEffect(() => { 274 + if (enabled && boardKey && posts.length === 0 && !cacheEntry) { 275 + setIsLoading(true); 276 + } else if (!isLoading) { 277 + setIsLoading(false); 278 + } 279 + }, [enabled, boardKey, posts.length, cacheEntry, isLoading]); 280 + 281 + return { 282 + posts, 283 + isLoading, 284 + isLoadingMore, 285 + hasMore, 286 + currentPage, 287 + totalPages, 288 + loadMore, 289 + refresh, 290 + error, 291 + isStale, 292 + }; 293 + }
+14 -4
src/lib/hooks/useBoardItems.tsx
··· 7 7 import { LIST_COLLECTION, LIST_ITEM_COLLECTION } from "@/constants"; 8 8 import { BoardItem, useBoardItemsStore } from "../stores/boardItems"; 9 9 import { getAllRecords } from "../records"; 10 + import { getPdsAgent } from "../utils/pds"; 11 + import { useDidStore } from "../stores/did"; 10 12 11 - export function useBoardItems() { 13 + export function useBoardItems(did?: string | null) { 12 14 const { agent } = useAuth(); 13 15 const store = useBoardItemsStore(); 14 16 const [isLoading, setLoading] = useState(store.boardItems.size == 0); 17 + const didStore = useDidStore(); 15 18 16 19 useEffect(() => { 17 20 if (agent == null) return; 18 21 const loadItems = async () => { 19 22 try { 23 + console.log( 24 + `Loading board items for ${did ?? agent.assertDid} (raw: ${did})` 25 + ); 26 + 27 + const resolvedDid = did ?? agent.assertDid; 28 + const tempAgent = await getPdsAgent(resolvedDid, didStore, agent); 20 29 const boards = await getAllRecords({ 21 30 collection: LIST_ITEM_COLLECTION, 22 - repo: agent.assertDid, 31 + repo: resolvedDid, 23 32 limit: 100, 24 - agent, 33 + agent: tempAgent, 25 34 }); 26 35 27 36 for (const item of boards) { 28 37 const safeItem = BoardItem.safeParse(item.value); 29 38 if (safeItem.success) 30 39 store.setBoardItem(new AtUri(item.uri).rkey, safeItem.data); 31 - else console.warn(`${item.uri} could not be parsed safely`); 40 + else 41 + console.warn(`${item.uri} could not be parsed safely`, item.value); 32 42 } 33 43 } finally { 34 44 setLoading(false);
+23 -6
src/lib/hooks/useBoards.tsx
··· 6 6 import { Board, useBoardsStore } from "../stores/boards"; 7 7 import { LIST_COLLECTION } from "@/constants"; 8 8 import { useBoardItems } from "./useBoardItems"; 9 + import { Agent } from "http"; 10 + import { getPdsAgent } from "../utils/pds"; 11 + import { useDidStore } from "../stores/did"; 9 12 10 - export function useBoards() { 13 + export function useBoards(did?: string | null) { 11 14 const { agent } = useAuth(); 12 15 const store = useBoardsStore(); 13 - const [isLoading, setLoading] = useState(store.boards.size == 0); 16 + const didStore = useDidStore(); 17 + const [isLoading, setLoading] = useState( 18 + Object.entries(store.boards).length == 0 19 + ); 14 20 15 21 useEffect(() => { 16 22 if (agent == null) return; 17 23 const loadBoards = async () => { 18 24 try { 19 - const boards = await agent.com.atproto.repo.listRecords({ 25 + console.log( 26 + `Loading board items for ${did ?? agent.assertDid} (raw: ${did})` 27 + ); 28 + 29 + const resolvedDid = did ?? agent.assertDid; 30 + const tempAgent = await getPdsAgent(resolvedDid, didStore, agent); 31 + 32 + const boards = await tempAgent.com.atproto.repo.listRecords({ 20 33 collection: LIST_COLLECTION, 21 - repo: agent.assertDid, 34 + repo: did ?? agent.assertDid, 22 35 limit: 100, 23 36 }); 24 37 25 38 for (const board of boards.data.records) { 26 39 const safeBoard = Board.safeParse(board.value); 27 40 if (safeBoard.success) 28 - store.setBoard(new AtUri(board.uri).rkey, safeBoard.data); 41 + store.setBoard( 42 + resolvedDid, 43 + new AtUri(board.uri).rkey, 44 + safeBoard.data 45 + ); 29 46 } 30 47 } finally { 31 48 setLoading(false); ··· 33 50 } 34 51 }; 35 52 loadBoards(); 36 - }, [agent]); 53 + }, [agent, did]); 37 54 38 55 return { isLoading }; 39 56 }
+29 -1
src/lib/records.ts
··· 1 - import { Agent } from "@atproto/api"; 1 + import { Agent, AtUri } from "@atproto/api"; 2 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 2 3 import { Record } from "@atproto/api/dist/client/types/com/atproto/repo/listRecords"; 3 4 4 5 /** ··· 32 33 33 34 return records; 34 35 } 36 + 37 + export async function getAllPosts({ 38 + posts, 39 + agent, 40 + }: { 41 + posts: AtUri[]; 42 + agent: Agent; 43 + }) { 44 + let records: PostView[] = []; 45 + let urisLeft = posts; 46 + 47 + while (urisLeft.length > 0) { 48 + const batch = urisLeft.slice(0, 25); 49 + urisLeft = urisLeft.slice(25); 50 + 51 + const res = await agent.getPosts({ 52 + uris: batch.map((it) => it.toString()), 53 + }); 54 + 55 + // Combine returned posts into our results 56 + if (res.success && res.data.posts) { 57 + records = records.concat(res.data.posts); 58 + } 59 + } 60 + 61 + return records; 62 + }
+1 -1
src/lib/stores/boardItems.tsx
··· 7 7 url: z.string(), 8 8 list: z.string(), 9 9 $type: z.string(), 10 - createdAt: z.string(), 10 + createdAt: z.string().optional(), 11 11 }); 12 12 13 13 export type BoardItem = z.infer<typeof BoardItem>;
+53 -20
src/lib/stores/boards.tsx
··· 1 1 import { create } from "zustand"; 2 2 import { persist } from "zustand/middleware"; 3 3 import * as z from "zod"; 4 - import { createMapStorage } from "../utils/mapStorage"; 5 4 6 5 export const Board = z.object({ 7 6 name: z.string(), ··· 10 9 11 10 export type Board = z.infer<typeof Board>; 12 11 13 - type FeedDefsState = { 14 - boards: Map<string, Board>; 15 - setBoard: (rkey: string, board: Board) => void; 16 - removeBoard: (rkey: string) => void; 12 + type BoardsState = { 13 + boards: Record<string, Record<string, Board>>; 17 14 isLoading: boolean; 18 15 setLoading: (value: boolean) => void; 16 + setBoard: (did: string, rkey: string, board: Board) => void; 17 + removeBoard: (did: string, rkey: string) => void; 18 + getBoards: (did: string) => Record<string, Board> | undefined; 19 + getBoardsAsEntries: (did: string) => [string, Board][] | undefined; 20 + getAllBoards: () => Record<string, Record<string, Board>>; 21 + clearBoards: (did?: string) => void; 19 22 }; 20 23 21 - export const useBoardsStore = create<FeedDefsState>()( 24 + export const useBoardsStore = create<BoardsState>()( 22 25 persist( 23 - (set) => ({ 24 - boards: new Map(), 25 - setBoard: (rkey, board) => 26 + (set, get) => ({ 27 + boards: {}, 28 + isLoading: true, 29 + 30 + setLoading: (value) => set(() => ({ isLoading: value })), 31 + 32 + setBoard: (did, rkey, board) => 26 33 set((state) => ({ 27 - boards: new Map(state.boards).set(rkey, board), 34 + boards: { 35 + ...state.boards, 36 + [did]: { 37 + ...state.boards[did], 38 + [rkey]: board, 39 + }, 40 + }, 28 41 })), 29 - removeBoard: (rkey) => 42 + 43 + removeBoard: (did, rkey) => 30 44 set((state) => { 31 - const newMap = new Map(state.boards); 32 - newMap.delete(rkey); 45 + const userBoards = state.boards[did]; 46 + if (!userBoards || !userBoards[rkey]) return state; 47 + 48 + const { [rkey]: removed, ...rest } = userBoards; 33 49 return { 34 - boards: newMap, 50 + boards: { 51 + ...state.boards, 52 + [did]: rest, 53 + }, 35 54 }; 36 55 }), 37 - isLoading: true, 38 - setLoading(value) { 39 - set(() => ({ 40 - isLoading: value, 41 - })); 56 + 57 + getBoards: (did) => get().boards[did], 58 + 59 + getBoardsAsEntries: (did) => { 60 + const boards = get().boards[did]; 61 + return boards ? Object.entries(boards) : undefined; 42 62 }, 63 + 64 + getAllBoards: () => get().boards, 65 + 66 + clearBoards: (did) => 67 + set((state) => { 68 + if (did) { 69 + const { [did]: removed, ...rest } = state.boards; 70 + return { boards: rest }; 71 + } else { 72 + return { boards: {} }; 73 + } 74 + }), 43 75 }), 44 76 { 45 77 name: "boards", 46 78 partialize: (state) => ({ 47 79 boards: state.boards, 48 80 }), 49 - storage: createMapStorage("boards"), 81 + version: 2, 82 + // No need for custom storage anymore! 50 83 } 51 84 ) 52 85 );
+129
src/lib/stores/did.tsx
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import { DidDocument } from "@atcute/identity"; 4 + import { createMapStorage } from "../utils/mapStorage"; 5 + 6 + // Cache policy 7 + const STALE_AFTER = 5 * 60 * 1000; // 5 minutes 8 + const EXPIRE_AFTER = 60 * 60 * 1000; // 1 hour 9 + 10 + export type CacheResult = { 11 + did: string; 12 + doc: DidDocument; 13 + updatedAt: number; 14 + stale: boolean; 15 + expired: boolean; 16 + }; 17 + 18 + export type DidCache = { 19 + dids: Map<string, CacheResult>; 20 + setDid: (did: string, doc: DidDocument) => CacheResult; 21 + getDid: (did: string) => CacheResult | undefined; 22 + checkCache: (did: string) => CacheResult | null; 23 + refreshCache: ( 24 + did: string, 25 + fetchFn: () => Promise<DidDocument | null>, 26 + prev?: CacheResult 27 + ) => Promise<void>; 28 + clearEntry: (did: string) => void; 29 + clear: () => void; 30 + }; 31 + 32 + export const useDidStore = create<DidCache>()( 33 + persist( 34 + (set, get) => ({ 35 + dids: new Map(), 36 + 37 + setDid: (did, doc) => { 38 + const now = Date.now(); 39 + const entry: CacheResult = { 40 + did, 41 + doc, 42 + updatedAt: now, 43 + stale: false, 44 + expired: false, 45 + }; 46 + set((state) => ({ 47 + dids: new Map(state.dids).set(did, entry), 48 + })); 49 + return entry; 50 + }, 51 + 52 + getDid: (did) => { 53 + return get().dids.get(did); 54 + }, 55 + 56 + checkCache: (did) => { 57 + const entry = get().dids.get(did); 58 + if (!entry) return null; 59 + 60 + const now = Date.now(); 61 + const age = now - entry.updatedAt; 62 + const stale = age > STALE_AFTER; 63 + const expired = age > EXPIRE_AFTER; 64 + 65 + if (stale !== entry.stale || expired !== entry.expired) { 66 + const updated: CacheResult = { 67 + ...entry, 68 + stale, 69 + expired, 70 + }; 71 + set((state) => ({ 72 + dids: new Map(state.dids).set(did, updated), 73 + })); 74 + return updated; 75 + } 76 + 77 + return entry; 78 + }, 79 + 80 + refreshCache: async ( 81 + did: string, 82 + fetchFn: () => Promise<DidDocument | null>, 83 + prev?: CacheResult 84 + ) => { 85 + try { 86 + const doc = await fetchFn(); 87 + if (doc) { 88 + get().setDid(did, doc); 89 + } else if (prev) { 90 + // keep existing but mark as expired 91 + set((state) => { 92 + const updated: CacheResult = { 93 + ...prev, 94 + stale: true, 95 + expired: true, 96 + }; 97 + return { 98 + dids: new Map(state.dids).set(did, updated), 99 + }; 100 + }); 101 + } else { 102 + get().clearEntry(did); 103 + } 104 + } catch { 105 + // network or validation failure — don't overwrite unless necessary 106 + } 107 + }, 108 + 109 + clearEntry: (did) => { 110 + set((state) => { 111 + const map = new Map(state.dids); 112 + map.delete(did); 113 + return { dids: map }; 114 + }); 115 + }, 116 + 117 + clear: () => { 118 + set(() => ({ dids: new Map() })); 119 + }, 120 + }), 121 + { 122 + name: "dids", 123 + partialize: (state) => ({ 124 + dids: state.dids, 125 + }), 126 + storage: createMapStorage("dids"), 127 + } 128 + ) 129 + );
+269
src/lib/stores/posts.tsx
··· 1 + import { create } from "zustand"; 2 + import { persist } from "zustand/middleware"; 3 + import { PostView } from "@atproto/api/dist/client/types/app/bsky/feed/defs"; 4 + import { createMapStorage } from "../utils/mapStorage"; 5 + 6 + // Cache policy 7 + const STALE_AFTER = 5 * 60 * 1000; // 5 minutes 8 + const EXPIRE_AFTER = 60 * 60 * 1000; // 1 hour 9 + 10 + export interface PostWithIndex { 11 + post: PostView; 12 + // 'index' is the image index to render, NOT a sort key. Do not sort by this. 13 + index: number; 14 + } 15 + 16 + export interface BoardPostsData { 17 + posts: PostWithIndex[]; 18 + totalItems: number; 19 + loadedPages: number[]; 20 + } 21 + 22 + export interface CacheResult { 23 + boardKey: string; 24 + data: BoardPostsData; 25 + updatedAt: number; 26 + stale: boolean; 27 + expired: boolean; 28 + } 29 + 30 + export interface PostsCache { 31 + boards: Map<string, CacheResult>; 32 + setBoardPosts: ( 33 + boardKey: string, 34 + posts: [number, PostView][], 35 + page: number, 36 + pageSize: number, 37 + totalItems: number 38 + ) => CacheResult; 39 + getBoardPosts: ( 40 + boardKey: string, 41 + page: number, 42 + pageSize: number 43 + ) => [number, PostView][]; 44 + checkCache: (boardKey: string) => CacheResult | null; 45 + refreshCache: ( 46 + boardKey: string, 47 + fetchFn: () => Promise<BoardPostsData | null>, 48 + prev?: CacheResult 49 + ) => Promise<void>; 50 + appendBoardPosts: ( 51 + boardKey: string, 52 + posts: [number, PostView][], 53 + page: number 54 + ) => CacheResult | null; 55 + hasCachedPage: (boardKey: string, page: number) => boolean; 56 + getTotalPages: (boardKey: string, pageSize: number) => number; 57 + clearEntry: (boardKey: string) => void; 58 + clear: () => void; 59 + } 60 + 61 + function makeKey(p: PostWithIndex) { 62 + return `${p.post.uri}#${p.index}`; 63 + } 64 + 65 + export const usePostsStore = create<PostsCache>()( 66 + persist( 67 + (set, get) => ({ 68 + boards: new Map(), 69 + 70 + setBoardPosts: (boardKey, posts, page, pageSize, totalItems) => { 71 + const now = Date.now(); 72 + const newPosts = posts.map(([index, post]) => ({ post, index })); 73 + 74 + const existingEntry = get().boards.get(boardKey); 75 + let combined: PostWithIndex[] = []; 76 + let loadedPages: number[] = []; 77 + 78 + if (!existingEntry) { 79 + combined = [...newPosts]; 80 + loadedPages = [page]; 81 + } else if (page === 0) { 82 + // Replace only page 0 segment: prepend new posts, keep existing non-duplicated after 83 + const existing = existingEntry.data.posts; 84 + const seenNew = new Set(newPosts.map((p) => makeKey(p))); 85 + const rest = existing.filter((p) => !seenNew.has(makeKey(p))); 86 + combined = [...newPosts, ...rest]; 87 + loadedPages = Array.from( 88 + new Set([0, ...(existingEntry.data.loadedPages || [])]) 89 + ); 90 + } else { 91 + const existing = existingEntry.data.posts; 92 + const seen = new Set(existing.map((p) => makeKey(p))); 93 + const dedupNew = newPosts.filter((p) => !seen.has(makeKey(p))); 94 + combined = [...existing, ...dedupNew]; 95 + loadedPages = Array.from( 96 + new Set([...(existingEntry.data.loadedPages || []), page]) 97 + ); 98 + } 99 + 100 + const entry: CacheResult = { 101 + boardKey, 102 + data: { 103 + posts: combined, 104 + totalItems, 105 + loadedPages, 106 + }, 107 + updatedAt: now, 108 + stale: false, 109 + expired: false, 110 + }; 111 + 112 + set((state) => ({ 113 + boards: new Map(state.boards).set(boardKey, entry), 114 + })); 115 + 116 + return entry; 117 + }, 118 + 119 + appendBoardPosts: (boardKey, posts, page) => { 120 + const existingEntry = get().boards.get(boardKey); 121 + if (!existingEntry) return null; 122 + 123 + const now = Date.now(); 124 + const newPosts = posts.map(([index, post]) => ({ post, index })); 125 + 126 + // Preserve order and dedupe by (uri, index) 127 + const existing = existingEntry.data.posts; 128 + const seen = new Set(existing.map((p) => makeKey(p))); 129 + const toAdd = newPosts.filter((p) => !seen.has(makeKey(p))); 130 + const combined = [...existing, ...toAdd]; 131 + 132 + const loadedPages = Array.from( 133 + new Set([...(existingEntry.data.loadedPages || []), page]) 134 + ); 135 + 136 + const entry: CacheResult = { 137 + ...existingEntry, 138 + data: { 139 + ...existingEntry.data, 140 + posts: combined, 141 + loadedPages, 142 + }, 143 + updatedAt: now, 144 + stale: false, 145 + expired: false, 146 + }; 147 + 148 + set((state) => ({ 149 + boards: new Map(state.boards).set(boardKey, entry), 150 + })); 151 + 152 + return entry; 153 + }, 154 + 155 + getBoardPosts: (boardKey, page, pageSize) => { 156 + const entry = get().boards.get(boardKey); 157 + if (!entry) return []; 158 + 159 + const startIndex = page * pageSize; 160 + const endIndex = startIndex + pageSize; 161 + 162 + return entry.data.posts 163 + .slice(startIndex, endIndex) 164 + .map(({ post, index }) => [index, post] as [number, PostView]); 165 + }, 166 + 167 + checkCache: (boardKey) => { 168 + const entry = get().boards.get(boardKey); 169 + if (!entry) return null; 170 + 171 + const now = Date.now(); 172 + const age = now - entry.updatedAt; 173 + const stale = age > STALE_AFTER; 174 + const expired = age > EXPIRE_AFTER; 175 + 176 + if (stale !== entry.stale || expired !== entry.expired) { 177 + const updated: CacheResult = { 178 + ...entry, 179 + stale, 180 + expired, 181 + }; 182 + 183 + set((state) => ({ 184 + boards: new Map(state.boards).set(boardKey, updated), 185 + })); 186 + 187 + return updated; 188 + } 189 + 190 + return entry; 191 + }, 192 + 193 + refreshCache: async ( 194 + boardKey: string, 195 + fetchFn: () => Promise<BoardPostsData | null>, 196 + prev?: CacheResult 197 + ) => { 198 + try { 199 + const data = await fetchFn(); 200 + if (data) { 201 + const now = Date.now(); 202 + const entry: CacheResult = { 203 + boardKey, 204 + data, 205 + updatedAt: now, 206 + stale: false, 207 + expired: false, 208 + }; 209 + 210 + set((state) => ({ 211 + boards: new Map(state.boards).set(boardKey, entry), 212 + })); 213 + } else if (prev) { 214 + // Keep existing but mark as expired 215 + set((state) => { 216 + const updated: CacheResult = { 217 + ...prev, 218 + stale: true, 219 + expired: true, 220 + }; 221 + return { 222 + boards: new Map(state.boards).set(boardKey, updated), 223 + }; 224 + }); 225 + } else { 226 + get().clearEntry(boardKey); 227 + } 228 + } catch { 229 + // Network or validation failure — don't overwrite unless necessary 230 + } 231 + }, 232 + 233 + hasCachedPage: (boardKey, page) => { 234 + try { 235 + const entry = get().boards.get(boardKey); 236 + return entry?.data.loadedPages.includes(page) ?? false; 237 + } catch (err) { 238 + console.error("Failed to check cached page", err); 239 + return false; 240 + } 241 + }, 242 + 243 + getTotalPages: (boardKey, pageSize) => { 244 + const entry = get().boards.get(boardKey); 245 + if (!entry) return 0; 246 + return Math.ceil(entry.data.totalItems / pageSize); 247 + }, 248 + 249 + clearEntry: (boardKey) => { 250 + set((state) => { 251 + const map = new Map(state.boards); 252 + map.delete(boardKey); 253 + return { boards: map }; 254 + }); 255 + }, 256 + 257 + clear: () => { 258 + set(() => ({ boards: new Map() })); 259 + }, 260 + }), 261 + { 262 + name: "posts", 263 + partialize: (state) => ({ 264 + boards: state.boards, 265 + }), 266 + storage: createMapStorage("boards"), 267 + } 268 + ) 269 + );
-2
src/lib/utils/mapStorage.ts
··· 21 21 const mapValue = newValue.state?.[key]; 22 22 const serializedMap = 23 23 mapValue instanceof Map ? Array.from(mapValue.entries()) : []; 24 - 25 24 const str = JSON.stringify({ 26 25 ...newValue, 27 26 state: { ··· 31 30 }); 32 31 localStorage.setItem(name, str); 33 32 }, 34 - 35 33 removeItem: (name) => localStorage.removeItem(name), 36 34 }; 37 35 }
+43
src/lib/utils/pds.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { DidCache } from "../stores/did"; 3 + import { PLC_DIRECTORY } from "@/constants"; 4 + import { 5 + CompositeDidDocumentResolver, 6 + PlcDidDocumentResolver, 7 + WebDidDocumentResolver, 8 + } from "@atcute/identity-resolver"; 9 + import { Did } from "@atcute/lexicons"; 10 + 11 + export async function getPdsAgent( 12 + did: string | null, 13 + didStore: DidCache, 14 + defaultAgent: Agent 15 + ) { 16 + const docResolver = new CompositeDidDocumentResolver({ 17 + methods: { 18 + plc: new PlcDidDocumentResolver(), 19 + web: new WebDidDocumentResolver(), 20 + }, 21 + }); 22 + 23 + if (!did) { 24 + return defaultAgent; 25 + } else { 26 + let didDoc = didStore.getDid(did); 27 + if (!didDoc) { 28 + const doc = await docResolver.resolve(did as Did<"plc">); 29 + didDoc = didStore.setDid(did, doc); 30 + } 31 + 32 + if (!didDoc?.doc?.service) 33 + throw Error("DID document doesn't include 'service'"); 34 + const pdsUrl = didDoc?.doc?.service.filter((e) => e.id == "#atproto_pds")[0] 35 + .serviceEndpoint; 36 + 37 + if (!pdsUrl) throw Error("DID doesn't include atproto"); 38 + if (typeof pdsUrl != "string") 39 + throw Error("'#atproto_pds' service endpoint isn't a string"); 40 + 41 + return new Agent({ service: pdsUrl }); 42 + } 43 + }