this repo has no description
1
fork

Configure Feed

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

Add split-panel file preview, UI cleanup, and fix document name flicker [CL-204] [CL-206] [CL-264] [CL-286]

Split-panel preview: PanelShell renders two separate panels via sidePanel
prop — side-by-side on large screens, stacked on mobile. Active file
highlighted in list/grid. PreviewPaneHeader shows document name, download,
and close controls.

Image preview: YARL lightbox with Phosphor zoom icons, top-aligned,
OpakeLogoSquares loading skeleton, client-side image resizing.

Markdown preview: react-markdown with remark-gfm, syntax highlighting via
rehype-highlight, prose styling.

Directory README: GitHub-style readme.md rendering above file list with
expand/collapse at 300px.

Flicker fix: fetchAll swaps items atomically instead of clearing to empty
first, preventing skeleton flash on refetch.

UI cleanup: remove E2E encrypted badge, storage indicator, Trash and
Encrypted sidebar items. Notification bell opens empty portal dropdown
without unread indicator. User menu uses KeyIcon linking to /devices/.
DropdownMenu converted to portal-based rendering.

+1182 -294
+3
CHANGELOG.md
··· 12 12 - Remove bearer token authentication fallback from AppView [#26](https://issues.opake.app/issues/26.html)s 13 13 14 14 ### Added 15 + - Add README.md header sections for all crates [#264](https://issues.opake.app/issues/264.html) 16 + - Add web onboarding flow for first-time identity setup [#265](https://issues.opake.app/issues/265.html) 17 + - Add MDX rendering for docs and front page [#256](https://issues.opake.app/issues/256.html) 15 18 - Add web sharing UI with grant management [#149](https://issues.opake.app/issues/149.html) 16 19 - Add settings page with AppView URL configuration [#277](https://issues.opake.app/issues/277.html) 17 20 - Add device settings link in sidebar with account info [#276](https://issues.opake.app/issues/276.html)
+34
web/bun.lock
··· 13 13 "immer": "^11.1.4", 14 14 "react": "^19.2.4", 15 15 "react-dom": "^19.2.4", 16 + "react-markdown": "^10.1.0", 17 + "react-syntax-highlighter": "^16.1.1", 16 18 "tailwind-merge": "^3.5.0", 19 + "yet-another-react-lightbox": "^3.29.1", 17 20 "zustand": "^5.0.11", 18 21 }, 19 22 "devDependencies": { ··· 24 27 "@types/node": "^25.3.5", 25 28 "@types/react": "^19.2.14", 26 29 "@types/react-dom": "^19.2.3", 30 + "@types/react-syntax-highlighter": "^15.5.13", 27 31 "daisyui": "^5.5.19", 28 32 "eslint": "^10.0.3", 29 33 "eslint-config-prettier": "^10.1.8", ··· 342 346 343 347 "@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="], 344 348 349 + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], 350 + 345 351 "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], 346 352 347 353 "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], 354 + 355 + "@types/react-syntax-highlighter": ["@types/react-syntax-highlighter@15.5.13", "", { "dependencies": { "@types/react": "*" } }, "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA=="], 348 356 349 357 "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], 350 358 ··· 644 652 645 653 "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 646 654 655 + "fault": ["fault@1.0.4", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA=="], 656 + 647 657 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 648 658 649 659 "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], ··· 657 667 "flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="], 658 668 659 669 "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 670 + 671 + "format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="], 660 672 661 673 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 662 674 ··· 706 718 707 719 "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 708 720 721 + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], 722 + 709 723 "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], 710 724 711 725 "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], 712 726 713 727 "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], 714 728 729 + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], 730 + 715 731 "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], 716 732 717 733 "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], 718 734 735 + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], 736 + 737 + "highlightjs-vue": ["highlightjs-vue@1.0.0", "", {}, "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="], 738 + 739 + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], 740 + 719 741 "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], 720 742 721 743 "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], ··· 857 879 "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 858 880 859 881 "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], 882 + 883 + "lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="], 860 884 861 885 "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 862 886 ··· 1037 1061 "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], 1038 1062 1039 1063 "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], 1064 + 1065 + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], 1040 1066 1041 1067 "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], 1042 1068 ··· 1048 1074 1049 1075 "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], 1050 1076 1077 + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], 1078 + 1079 + "react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="], 1080 + 1051 1081 "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], 1052 1082 1053 1083 "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], ··· 1065 1095 "refa": ["refa@0.12.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0" } }, "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g=="], 1066 1096 1067 1097 "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=="], 1098 + 1099 + "refractor": ["refractor@5.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^9.0.0", "parse-entities": "^4.0.0" } }, "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw=="], 1068 1100 1069 1101 "regexp-ast-analysis": ["regexp-ast-analysis@0.7.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.1" } }, "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A=="], 1070 1102 ··· 1275 1307 "xmlbuilder2": ["xmlbuilder2@4.0.3", "", { "dependencies": { "@oozcitak/dom": "^2.0.2", "@oozcitak/infra": "^2.0.2", "@oozcitak/util": "^10.0.0", "js-yaml": "^4.1.1" } }, "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA=="], 1276 1308 1277 1309 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 1310 + 1311 + "yet-another-react-lightbox": ["yet-another-react-lightbox@3.29.1", "", { "peerDependencies": { "@types/react": "^16 || ^17 || ^18 || ^19", "@types/react-dom": "^16 || ^17 || ^18 || ^19", "react": "^16.8.0 || ^17 || ^18 || ^19", "react-dom": "^16.8.0 || ^17 || ^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0cpa+wlleiy2cWNjS9qrcY0+SgZQH/4PDx2uupLMI9Ofip1f7pCgZ95PlVp/EsFsO4ufwOTea51bkLhcEXJJSg=="], 1278 1312 1279 1313 "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1280 1314
+5 -1
web/package.json
··· 25 25 "immer": "^11.1.4", 26 26 "react": "^19.2.4", 27 27 "react-dom": "^19.2.4", 28 + "react-markdown": "^10.1.0", 29 + "react-syntax-highlighter": "^16.1.1", 30 + "remark-gfm": "^4.0.1", 28 31 "tailwind-merge": "^3.5.0", 32 + "yet-another-react-lightbox": "^3.29.1", 29 33 "zustand": "^5.0.11" 30 34 }, 31 35 "devDependencies": { ··· 36 40 "@types/node": "^25.3.5", 37 41 "@types/react": "^19.2.14", 38 42 "@types/react-dom": "^19.2.3", 43 + "@types/react-syntax-highlighter": "^15.5.13", 39 44 "daisyui": "^5.5.19", 40 45 "eslint": "^10.0.3", 41 46 "eslint-config-prettier": "^10.1.8", ··· 50 55 "jiti": "^2.6.1", 51 56 "prettier": "^3.8.1", 52 57 "prettier-plugin-tailwindcss": "^0.7.2", 53 - "remark-gfm": "^4.0.1", 54 58 "tailwindcss": "^4.2.1", 55 59 "typescript": "^5.9.3", 56 60 "typescript-eslint": "^8.56.1",
+80 -27
web/src/components/DropdownMenu.tsx
··· 1 - import { useEffect, useRef, type ComponentType, type ReactNode } from "react"; 1 + import { 2 + useCallback, 3 + useEffect, 4 + useRef, 5 + useState, 6 + type ComponentType, 7 + type ReactNode, 8 + } from "react"; 9 + import { createPortal } from "react-dom"; 2 10 3 11 interface DropdownMenuItem { 4 12 readonly icon: ComponentType<{ readonly size: number; readonly className?: string }>; ··· 11 19 readonly items: readonly DropdownMenuItem[]; 12 20 readonly triggerClassName?: string; 13 21 readonly align?: "left" | "right"; 22 + readonly emptyLabel?: string; 14 23 } 15 24 16 25 export function DropdownMenu({ ··· 18 27 items, 19 28 triggerClassName = "btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs", 20 29 align = "left", 30 + emptyLabel, 21 31 }: DropdownMenuProps) { 22 - const detailsRef = useRef<HTMLDetailsElement>(null); 32 + const [menuStyle, setMenuStyle] = useState<React.CSSProperties>({}); 33 + const [open, setOpen] = useState(false); 34 + const triggerRef = useRef<HTMLButtonElement>(null); 35 + const menuRef = useRef<HTMLUListElement>(null); 36 + 37 + const close = useCallback(() => setOpen(false), []); 38 + 39 + const toggle = useCallback(() => { 40 + setOpen((prev) => { 41 + if (!prev) { 42 + const rect = triggerRef.current?.getBoundingClientRect(); 43 + if (rect) { 44 + setMenuStyle({ 45 + position: "fixed", 46 + top: rect.bottom + 4, 47 + ...(align === "left" ? { right: window.innerWidth - rect.right } : { left: rect.left }), 48 + zIndex: 9999, 49 + }); 50 + } 51 + } 52 + return !prev; 53 + }); 54 + }, [align]); 23 55 24 56 useEffect(() => { 57 + if (!open) return; 25 58 const handleClickOutside = (e: MouseEvent) => { 26 - if (detailsRef.current?.open && !detailsRef.current.contains(e.target as Node)) { 27 - detailsRef.current.removeAttribute("open"); 28 - } 59 + const target = e.target as Node; 60 + if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) return; 61 + setOpen(false); 29 62 }; 30 - document.addEventListener("click", handleClickOutside); 31 - return () => document.removeEventListener("click", handleClickOutside); 32 - }, []); 63 + document.addEventListener("mousedown", handleClickOutside); 64 + return () => document.removeEventListener("mousedown", handleClickOutside); 65 + }, [open]); 33 66 34 67 return ( 35 - <details ref={detailsRef} className={`dropdown ${align === "left" ? "dropdown-end" : ""}`}> 36 - <summary className={triggerClassName}>{trigger}</summary> 37 - <ul className="menu dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-[100] w-42 rounded-xl border p-1"> 38 - {items.map(({ icon: Icon, label, onClick }) => ( 39 - <li key={label}> 40 - <button 41 - onClick={(e) => { 42 - e.currentTarget.closest("details")?.removeAttribute("open"); 43 - onClick?.(); 44 - }} 45 - className="text-secondary gap-2.5 text-xs" 46 - > 47 - <Icon size={13} className="text-text-muted" /> 48 - {label} 49 - </button> 50 - </li> 51 - ))} 52 - </ul> 53 - </details> 68 + <> 69 + <button 70 + ref={triggerRef} 71 + className={triggerClassName} 72 + onClick={toggle} 73 + aria-expanded={open} 74 + aria-haspopup="true" 75 + > 76 + {trigger} 77 + </button> 78 + {open && 79 + createPortal( 80 + <ul 81 + ref={menuRef} 82 + className="menu border-base-300/50 bg-base-100 shadow-panel-lg w-42 rounded-xl border p-1" 83 + style={menuStyle} 84 + > 85 + {items.length === 0 && emptyLabel ? ( 86 + <li className="text-caption text-text-faint px-2.5 py-2">{emptyLabel}</li> 87 + ) : ( 88 + items.map(({ icon: Icon, label, onClick }) => ( 89 + <li key={label}> 90 + <button 91 + onClick={() => { 92 + close(); 93 + onClick?.(); 94 + }} 95 + className="text-secondary gap-2.5 text-xs" 96 + > 97 + <Icon size={13} className="text-text-muted" /> 98 + {label} 99 + </button> 100 + </li> 101 + )) 102 + )} 103 + </ul>, 104 + document.body, 105 + )} 106 + </> 54 107 ); 55 108 }
+163
web/src/components/cabinet/DirectoryReadme.tsx
··· 1 + // Renders a decrypted README.md above the file list, GitHub-style. 2 + // Uses the same Suspense + promise-cache pattern as FilePreview. 3 + 4 + import { use, useEffect, useRef, useState } from "react"; 5 + import { CaretDownIcon, CaretUpIcon, FileTextIcon } from "@phosphor-icons/react"; 6 + import { MarkdownPreview } from "./MarkdownPreview"; 7 + import { decryptDocumentBlob } from "@/lib/preview"; 8 + import { useDocumentsStore } from "@/stores/documents"; 9 + import { useAuthStore } from "@/stores/auth"; 10 + import { base64ToUint8Array } from "@/lib/encoding"; 11 + import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 12 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 13 + 14 + const storage = new IndexedDbStorage(); 15 + 16 + const COLLAPSED_MAX_HEIGHT = 300; 17 + 18 + type ReadmeResult = 19 + | { readonly status: "ready"; readonly data: Uint8Array } 20 + | { readonly status: "error"; readonly message: string }; 21 + 22 + // Module-level cache, separate from FilePreview's cache. 23 + const readmeCache = new Map<string, Promise<ReadmeResult>>(); 24 + 25 + function getOrCreateReadmePromise(documentUri: string): Promise<ReadmeResult> { 26 + const cached = readmeCache.get(documentUri); 27 + if (cached) return cached; 28 + 29 + const promise = fetchAndDecryptReadme(documentUri); 30 + // eslint-disable-next-line functional/immutable-data -- module-level cache for Suspense stability 31 + readmeCache.set(documentUri, promise); 32 + return promise; 33 + } 34 + 35 + export function evictReadmeCache(documentUri: string): void { 36 + // eslint-disable-next-line functional/immutable-data -- module-level cache cleanup 37 + readmeCache.delete(documentUri); 38 + } 39 + 40 + async function fetchAndDecryptReadme(documentUri: string): Promise<ReadmeResult> { 41 + try { 42 + const state = useDocumentsStore.getState(); 43 + const record = state.documentRecords[documentUri] as PdsRecord<DocumentRecord> | undefined; 44 + if (!record) { 45 + return { status: "error", message: "Document record not found" }; 46 + } 47 + 48 + const authState = useAuthStore.getState(); 49 + if (authState.session.status !== "active") { 50 + return { status: "error", message: "Not authenticated" }; 51 + } 52 + 53 + const { did, pdsUrl } = authState.session; 54 + const session = await storage.loadSession(did); 55 + const identity = await storage.loadIdentity(did); 56 + const privateKey = base64ToUint8Array(identity.private_key); 57 + 58 + const storeItem = state.items[documentUri]; 59 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 60 + const knownMetadata: DocumentMetadata | undefined = storeItem?.decrypted 61 + ? { 62 + name: storeItem.name, 63 + mimeType: storeItem.mimeType, 64 + tags: storeItem.tags, 65 + description: storeItem.description, 66 + } 67 + : undefined; 68 + 69 + const blob = await decryptDocumentBlob(record, pdsUrl, did, privateKey, session, knownMetadata); 70 + return { status: "ready", data: blob.plaintext }; 71 + } catch (error) { 72 + return { 73 + status: "error", 74 + message: error instanceof Error ? error.message : "Failed to decrypt README", 75 + }; 76 + } 77 + } 78 + 79 + interface DirectoryReadmeProps { 80 + readonly documentUri: string; 81 + } 82 + 83 + export function DirectoryReadme({ documentUri }: DirectoryReadmeProps) { 84 + const result = use(getOrCreateReadmePromise(documentUri)); 85 + const [expanded, setExpanded] = useState(false); 86 + const [fullHeight, setFullHeight] = useState<number | null>(null); 87 + const contentRef = useRef<HTMLDivElement>(null); 88 + 89 + const needsExpand = fullHeight !== null && fullHeight > COLLAPSED_MAX_HEIGHT; 90 + 91 + // Measure content height after render 92 + useEffect(() => { 93 + if (!contentRef.current) return; 94 + setFullHeight(contentRef.current.scrollHeight); 95 + }, [result]); 96 + 97 + if (result.status === "error") return null; 98 + 99 + const maxHeight = expanded ? (fullHeight ?? undefined) : COLLAPSED_MAX_HEIGHT; 100 + 101 + return ( 102 + <div className="border-base-300/50 bg-base-200/30 overflow-hidden rounded-xl border"> 103 + {/* Header */} 104 + <div className="border-base-300/50 flex items-center gap-2 border-b px-4 py-2"> 105 + <FileTextIcon size={14} className="text-text-faint" /> 106 + <span className="text-caption text-text-muted font-medium">README.md</span> 107 + </div> 108 + 109 + {/* Content */} 110 + <div className="relative"> 111 + <div 112 + ref={contentRef} 113 + className="overflow-hidden transition-[max-height] duration-300 ease-in-out" 114 + style={{ maxHeight }} 115 + > 116 + <MarkdownPreview data={result.data} /> 117 + </div> 118 + 119 + {/* Fade + expand button */} 120 + {needsExpand && !expanded && ( 121 + <div className="from-base-100/0 via-base-100/80 to-base-100 absolute inset-x-0 bottom-0 flex items-end bg-gradient-to-b pt-16 pb-3 pl-6"> 122 + <button 123 + onClick={() => setExpanded(true)} 124 + className="btn btn-ghost btn-xs text-text-muted gap-1" 125 + > 126 + <CaretDownIcon size={12} /> 127 + Read more 128 + </button> 129 + </div> 130 + )} 131 + 132 + {needsExpand && expanded && ( 133 + <div className="flex pb-3 pl-6"> 134 + <button 135 + onClick={() => setExpanded(false)} 136 + className="btn btn-ghost btn-xs text-text-muted gap-1" 137 + > 138 + <CaretUpIcon size={12} /> 139 + Show less 140 + </button> 141 + </div> 142 + )} 143 + </div> 144 + </div> 145 + ); 146 + } 147 + 148 + export function DirectoryReadmeSkeleton() { 149 + return ( 150 + <div className="border-base-300/50 bg-base-200/30 overflow-hidden rounded-xl border"> 151 + <div className="border-base-300/50 flex items-center gap-2 border-b px-4 py-2"> 152 + <FileTextIcon size={14} className="text-text-faint" /> 153 + <span className="text-caption text-text-muted font-medium">README.md</span> 154 + </div> 155 + <div className="space-y-3 p-6"> 156 + <div className="skeleton h-5 w-48" /> 157 + <div className="skeleton h-4 w-full" /> 158 + <div className="skeleton h-4 w-3/4" /> 159 + <div className="skeleton h-4 w-5/6" /> 160 + </div> 161 + </div> 162 + ); 163 + }
+4
web/src/components/cabinet/FileActionMenu.tsx
··· 2 2 ArrowBendUpRightIcon, 3 3 DotsThreeVerticalIcon, 4 4 DownloadSimpleIcon, 5 + EyeIcon, 5 6 PencilSimpleIcon, 6 7 ShareNetworkIcon, 7 8 TrashIcon, ··· 12 13 13 14 interface FileActionMenuProps { 14 15 readonly item: FileItem; 16 + readonly onPreview?: () => void; 15 17 readonly onEditMetadata?: () => void; 16 18 readonly onRename?: () => void; 17 19 readonly onMove?: () => void; ··· 23 25 24 26 export function FileActionMenu({ 25 27 item, 28 + onPreview, 26 29 onEditMetadata, 27 30 onRename, 28 31 onMove, ··· 54 57 { icon: TrashIcon, label: "Delete", onClick: onDeleteFolder }, 55 58 ] 56 59 : [ 60 + ...(onPreview ? [{ icon: EyeIcon, label: "Preview", onClick: onPreview }] : []), 57 61 { icon: PencilSimpleIcon, label: "Edit details", onClick: onEditMetadata }, 58 62 { icon: ShareNetworkIcon, label: "Share\u2026", onClick: onShare }, 59 63 { icon: ArrowBendUpRightIcon, label: "Move to\u2026", onClick: onMove },
+17 -7
web/src/components/cabinet/FileGridCard.tsx
··· 6 6 7 7 interface FileGridCardProps { 8 8 readonly item: FileItem; 9 + readonly isActive?: boolean; 9 10 readonly onClick: () => void; 11 + readonly onPreview?: () => void; 10 12 readonly onEditMetadata?: () => void; 11 13 readonly onRename?: () => void; 12 14 readonly onMove?: () => void; ··· 18 20 19 21 export function FileGridCard({ 20 22 item, 23 + isActive, 21 24 onClick, 25 + onPreview, 22 26 onEditMetadata, 23 27 onRename, 24 28 onMove, ··· 29 33 }: FileGridCardProps) { 30 34 const { bg, text } = fileIconColors(item); 31 35 const isFolder = item.kind === "folder"; 36 + const isClickable = isFolder || item.decrypted; 37 + 38 + const cardClassName = [ 39 + "card border-base-300/50 bg-base-100 shadow-panel-sm hover:border-base-300 hover:shadow-panel-md border p-4 transition-all", 40 + isClickable ? "cursor-pointer" : "", 41 + isActive ? "border-primary/30 shadow-panel-md" : "", 42 + ].join(" "); 32 43 33 44 return ( 34 45 <div 35 - onClick={isFolder ? onClick : undefined} 46 + onClick={isClickable ? onClick : undefined} 36 47 onKeyDown={ 37 - isFolder 48 + isClickable 38 49 ? (e) => { 39 50 if (e.key === "Enter" || e.key === " ") { 40 51 e.preventDefault(); ··· 43 54 } 44 55 : undefined 45 56 } 46 - role={isFolder ? "button" : "article"} 47 - tabIndex={isFolder ? 0 : undefined} 57 + role={isClickable ? "button" : "article"} 58 + tabIndex={isClickable ? 0 : undefined} 48 59 aria-label={ 49 60 item.decrypted 50 61 ? `${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}` 51 62 : "Decrypting…" 52 63 } 53 - className={`card border-base-300/50 bg-base-100 shadow-panel-sm hover:border-base-300 hover:shadow-panel-md border p-4 transition-all ${ 54 - isFolder ? "cursor-pointer" : "" 55 - }`} 64 + className={cardClassName} 56 65 > 57 66 <div className="mb-3 flex items-start justify-between"> 58 67 <div className={`flex size-9.5 items-center justify-center rounded-[10px] ${bg} ${text}`}> ··· 61 70 <div className="flex items-center gap-1"> 62 71 <FileActionMenu 63 72 item={item} 73 + onPreview={onPreview} 64 74 onEditMetadata={onEditMetadata} 65 75 onRename={onRename} 66 76 onMove={onMove}
+17 -7
web/src/components/cabinet/FileListRow.tsx
··· 6 6 7 7 interface FileListRowProps { 8 8 readonly item: FileItem; 9 + readonly isActive?: boolean; 9 10 readonly onClick: () => void; 11 + readonly onPreview?: () => void; 10 12 readonly onEditMetadata?: () => void; 11 13 readonly onRename?: () => void; 12 14 readonly onMove?: () => void; ··· 18 20 19 21 export function FileListRow({ 20 22 item, 23 + isActive, 21 24 onClick, 25 + onPreview, 22 26 onEditMetadata, 23 27 onRename, 24 28 onMove, ··· 29 33 }: FileListRowProps) { 30 34 const { bg, text } = fileIconColors(item); 31 35 const isFolder = item.kind === "folder"; 36 + const isClickable = isFolder || item.decrypted; 37 + 38 + const rowClassName = [ 39 + "hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors", 40 + isClickable ? "cursor-pointer" : "", 41 + isActive ? "bg-bg-hover ring-1 ring-primary/20" : "", 42 + ].join(" "); 32 43 33 44 return ( 34 45 <div 35 - onClick={isFolder ? onClick : undefined} 46 + onClick={isClickable ? onClick : undefined} 36 47 onKeyDown={ 37 - isFolder 48 + isClickable 38 49 ? (e) => { 39 50 if (e.key === "Enter" || e.key === " ") { 40 51 e.preventDefault(); ··· 43 54 } 44 55 : undefined 45 56 } 46 - role={isFolder ? "button" : "row"} 47 - tabIndex={isFolder ? 0 : undefined} 57 + role={isClickable ? "button" : "row"} 58 + tabIndex={isClickable ? 0 : undefined} 48 59 aria-label={ 49 60 item.decrypted 50 61 ? `${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}` 51 62 : "Decrypting…" 52 63 } 53 - className={`hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors ${ 54 - isFolder ? "cursor-pointer" : "" 55 - }`} 64 + className={rowClassName} 56 65 > 57 66 {/* Actions */} 58 67 <div className="w-6"> 59 68 <FileActionMenu 60 69 item={item} 70 + onPreview={onPreview} 61 71 onEditMetadata={onEditMetadata} 62 72 onRename={onRename} 63 73 onMove={onMove}
+142
web/src/components/cabinet/FilePreview.tsx
··· 1 + // Preview container — handles blob decryption and dispatches to the 2 + // appropriate renderer based on MIME type. 3 + 4 + import { use } from "react"; 5 + import { DownloadSimpleIcon } from "@phosphor-icons/react"; 6 + import { ImagePreview } from "./ImagePreview"; 7 + import { MarkdownPreview } from "./MarkdownPreview"; 8 + import { decryptDocumentBlob, type DecryptedBlob } from "@/lib/preview"; 9 + import { useDocumentsStore } from "@/stores/documents"; 10 + import { useAuthStore } from "@/stores/auth"; 11 + import { base64ToUint8Array } from "@/lib/encoding"; 12 + import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 13 + import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 14 + 15 + const storage = new IndexedDbStorage(); 16 + 17 + interface FilePreviewProps { 18 + readonly documentUri: string; 19 + } 20 + 21 + function isImageMime(mime: string): boolean { 22 + return mime.startsWith("image/"); 23 + } 24 + 25 + function isMarkdownMime(mime: string, filename: string): boolean { 26 + return mime === "text/markdown" || filename.endsWith(".md") || filename.endsWith(".mdx"); 27 + } 28 + 29 + type DecryptResult = 30 + | { readonly status: "ready"; readonly blob: DecryptedBlob } 31 + | { readonly status: "error"; readonly message: string } 32 + | { readonly status: "unsupported"; readonly mimeType: string }; 33 + 34 + // Module-level promise cache — survives Suspense unmount/remount cycles. 35 + // Keyed by documentUri so each document decrypts exactly once. 36 + const decryptCache = new Map<string, Promise<DecryptResult>>(); 37 + 38 + function getOrCreateDecryptPromise(documentUri: string): Promise<DecryptResult> { 39 + const cached = decryptCache.get(documentUri); 40 + if (cached) return cached; 41 + 42 + const promise = fetchAndDecrypt(documentUri); 43 + // eslint-disable-next-line functional/immutable-data -- module-level cache for Suspense stability 44 + decryptCache.set(documentUri, promise); 45 + return promise; 46 + } 47 + 48 + /** Call when navigating away from a preview to free the cached result. */ 49 + export function evictPreviewCache(documentUri: string): void { 50 + // eslint-disable-next-line functional/immutable-data -- module-level cache cleanup 51 + decryptCache.delete(documentUri); 52 + } 53 + 54 + async function fetchAndDecrypt(documentUri: string): Promise<DecryptResult> { 55 + try { 56 + const state = useDocumentsStore.getState(); 57 + const record = state.documentRecords[documentUri] as PdsRecord<DocumentRecord> | undefined; 58 + if (!record) { 59 + return { status: "error", message: "Document record not found" }; 60 + } 61 + 62 + const authState = useAuthStore.getState(); 63 + if (authState.session.status !== "active") { 64 + return { status: "error", message: "Not authenticated" }; 65 + } 66 + 67 + const { did, pdsUrl } = authState.session; 68 + const session = await storage.loadSession(did); 69 + const identity = await storage.loadIdentity(did); 70 + const privateKey = base64ToUint8Array(identity.private_key); 71 + 72 + const storeItem = state.items[documentUri]; 73 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: Record lookup 74 + const knownMetadata: DocumentMetadata | undefined = storeItem?.decrypted 75 + ? { 76 + name: storeItem.name, 77 + mimeType: storeItem.mimeType, 78 + tags: storeItem.tags, 79 + description: storeItem.description, 80 + } 81 + : undefined; 82 + 83 + const blob = await decryptDocumentBlob(record, pdsUrl, did, privateKey, session, knownMetadata); 84 + const mime = blob.metadata.mimeType ?? "application/octet-stream"; 85 + const filename = blob.metadata.name; 86 + 87 + if (isImageMime(mime) || isMarkdownMime(mime, filename)) { 88 + return { status: "ready", blob }; 89 + } 90 + return { status: "unsupported", mimeType: mime }; 91 + } catch (error) { 92 + return { 93 + status: "error", 94 + message: error instanceof Error ? error.message : "Decryption failed", 95 + }; 96 + } 97 + } 98 + 99 + export function FilePreview({ documentUri }: FilePreviewProps) { 100 + const downloadFile = useDocumentsStore((s) => s.downloadFile); 101 + const result = use(getOrCreateDecryptPromise(documentUri)); 102 + 103 + if (result.status === "error") { 104 + return ( 105 + <div className="flex h-full flex-col items-center justify-center gap-3 p-8"> 106 + <span className="text-ui text-text-muted">{result.message}</span> 107 + <DownloadButton onClick={() => void downloadFile(documentUri)} /> 108 + </div> 109 + ); 110 + } 111 + 112 + if (result.status === "unsupported") { 113 + return ( 114 + <div className="flex h-full flex-col items-center justify-center gap-3 p-8"> 115 + <span className="text-ui text-text-muted">Preview not available for {result.mimeType}</span> 116 + <DownloadButton onClick={() => void downloadFile(documentUri)} /> 117 + </div> 118 + ); 119 + } 120 + 121 + const { blob } = result; 122 + const mime = blob.metadata.mimeType ?? "application/octet-stream"; 123 + 124 + if (isImageMime(mime)) { 125 + return <ImagePreview data={blob.plaintext} mimeType={mime} />; 126 + } 127 + 128 + if (isMarkdownMime(mime, blob.metadata.name)) { 129 + return <MarkdownPreview data={blob.plaintext} />; 130 + } 131 + 132 + return null; 133 + } 134 + 135 + function DownloadButton({ onClick }: { readonly onClick: () => void }) { 136 + return ( 137 + <button onClick={onClick} className="btn btn-sm btn-primary gap-1.5"> 138 + <DownloadSimpleIcon size={14} /> 139 + Download 140 + </button> 141 + ); 142 + }
+79
web/src/components/cabinet/ImagePreview.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import Lightbox from "yet-another-react-lightbox"; 3 + import Inline from "yet-another-react-lightbox/plugins/inline"; 4 + import Zoom from "yet-another-react-lightbox/plugins/zoom"; 5 + import { MagnifyingGlassPlusIcon, MagnifyingGlassMinusIcon } from "@phosphor-icons/react"; 6 + import "yet-another-react-lightbox/styles.css"; 7 + import { OpakeLogoSquares } from "@/components/OpakeLogoSquares"; 8 + import { resizeImageBlob } from "@/lib/resizeImage"; 9 + 10 + interface ImagePreviewProps { 11 + readonly data: Uint8Array; 12 + readonly mimeType: string; 13 + } 14 + 15 + export function ImagePreview({ data, mimeType }: ImagePreviewProps) { 16 + const [blobUrl, setBlobUrl] = useState<string | null>(null); 17 + 18 + useEffect(() => { 19 + // eslint-disable-next-line functional/no-let -- mutable cleanup flag for async lifecycle 20 + let revoked = false; 21 + // eslint-disable-next-line functional/no-let -- tracks URL for cleanup 22 + let url: string | null = null; 23 + 24 + void resizeImageBlob(data, mimeType).then((resizedUrl) => { 25 + if (revoked) { 26 + URL.revokeObjectURL(resizedUrl); 27 + return; 28 + } 29 + url = resizedUrl; 30 + setBlobUrl(resizedUrl); 31 + }); 32 + 33 + return () => { 34 + revoked = true; 35 + if (url) URL.revokeObjectURL(url); 36 + }; 37 + }, [data, mimeType]); 38 + 39 + if (!blobUrl) { 40 + return ( 41 + <div className="flex h-full items-center justify-center p-8"> 42 + <OpakeLogoSquares size="lg" loading /> 43 + </div> 44 + ); 45 + } 46 + 47 + return ( 48 + <div className="relative h-full w-full overflow-hidden"> 49 + <Lightbox 50 + open 51 + close={() => undefined} 52 + slides={[{ src: blobUrl }]} 53 + plugins={[Inline, Zoom]} 54 + inline={{ style: { width: "100%", height: "100%" } }} 55 + carousel={{ finite: true }} 56 + render={{ 57 + buttonPrev: () => null, 58 + buttonNext: () => null, 59 + buttonClose: () => null, 60 + iconZoomIn: () => ( 61 + <button className="btn btn-sm btn-square" aria-label="Zoom in"> 62 + <MagnifyingGlassPlusIcon size={16} /> 63 + </button> 64 + ), 65 + iconZoomOut: () => ( 66 + <button className="btn btn-sm btn-square" aria-label="Zoom out"> 67 + <MagnifyingGlassMinusIcon size={16} /> 68 + </button> 69 + ), 70 + }} 71 + zoom={{ maxZoomPixelRatio: 4 }} 72 + styles={{ 73 + container: { backgroundColor: "transparent" }, 74 + slide: { alignItems: "flex-start", padding: "1rem" }, 75 + }} 76 + /> 77 + </div> 78 + ); 79 + }
+53
web/src/components/cabinet/MarkdownPreview.tsx
··· 1 + import { useMemo } from "react"; 2 + import Markdown from "react-markdown"; 3 + import remarkGfm from "remark-gfm"; 4 + import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 5 + import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; 6 + import type { ComponentPropsWithoutRef } from "react"; 7 + 8 + interface MarkdownPreviewProps { 9 + readonly data: Uint8Array; 10 + } 11 + 12 + export function MarkdownPreview({ data }: MarkdownPreviewProps) { 13 + const content = useMemo(() => new TextDecoder().decode(data), [data]); 14 + 15 + return ( 16 + <div className="h-full overflow-y-auto p-6"> 17 + <article className="prose prose-sm max-w-none"> 18 + <Markdown remarkPlugins={[remarkGfm]} components={{ code: CodeBlock }}> 19 + {content} 20 + </Markdown> 21 + </article> 22 + </div> 23 + ); 24 + } 25 + 26 + function CodeBlock({ className, children, ...props }: Readonly<ComponentPropsWithoutRef<"code">>) { 27 + const match = /language-(\w+)/.exec(className ?? ""); 28 + const codeString = Array.isArray(children) 29 + ? children 30 + .map((c) => (typeof c === "string" ? c : "")) 31 + .join("") 32 + .replace(/\n$/, "") 33 + : (typeof children === "string" ? children : "").replace(/\n$/, ""); 34 + 35 + if (!match) { 36 + return ( 37 + <code className={className} {...props}> 38 + {children} 39 + </code> 40 + ); 41 + } 42 + 43 + return ( 44 + <SyntaxHighlighter 45 + style={oneDark} 46 + language={match[1]} 47 + PreTag="pre" 48 + customStyle={{ borderRadius: "0.5rem", fontSize: "0.8125rem" }} 49 + > 50 + {codeString} 51 + </SyntaxHighlighter> 52 + ); 53 + }
+48 -34
web/src/components/cabinet/PanelContent.tsx
··· 1 - import { useRef } from "react"; 1 + import { Suspense, useRef } from "react"; 2 2 import { FolderIcon } from "@phosphor-icons/react"; 3 3 import { FileListRow } from "./FileListRow"; 4 4 import { FileGridCard } from "./FileGridCard"; 5 + import { DirectoryReadme, DirectoryReadmeSkeleton } from "./DirectoryReadme"; 5 6 import type { ConfirmDialogHandle } from "@/components/ConfirmDialog"; 6 7 import { DeleteConfirmDialog } from "./DeleteConfirmDialog"; 7 8 import { ··· 18 19 import { ShareDialog, type ShareDialogHandle } from "./ShareDialog"; 19 20 import { useDocumentsStore } from "@/stores/documents"; 20 21 import { getCryptoWorker } from "@/lib/worker"; 21 - import type { FileItem } from "./types"; 22 + import { isPreviewable, type FileItem } from "./types"; 22 23 23 24 interface PanelContentProps { 24 25 readonly items: readonly FileItem[]; 25 26 readonly viewMode: "list" | "grid"; 27 + readonly activeUri?: string; 26 28 readonly onOpen: (item: FileItem) => void; 29 + readonly onPreview?: (item: FileItem) => void; 27 30 readonly onDownload: (uri: string) => void; 28 31 readonly onDelete: (uri: string) => void; 29 32 readonly onDeleteFolder: (uri: string) => void; ··· 35 38 export function PanelContent({ 36 39 items, 37 40 viewMode, 41 + activeUri, 38 42 onOpen, 43 + onPreview, 39 44 onDownload, 40 45 onDelete, 41 46 onDeleteFolder, ··· 75 80 moveDialogRef.current?.show(item.uri, item.name, item.kind, currentParent, disabled); 76 81 }; 77 82 83 + const handleItemClick = (item: FileItem) => { 84 + if (item.kind === "folder") onOpen(item); 85 + else if (onPreview && isPreviewable(item)) onPreview(item); 86 + else onDownload(item.uri); 87 + }; 88 + 89 + const previewHandler = (item: FileItem) => 90 + onPreview && isPreviewable(item) ? () => onPreview(item) : undefined; 91 + 78 92 if (items.length === 0) { 79 93 return ( 80 94 <div className="hero py-16"> ··· 88 102 ); 89 103 } 90 104 105 + const readmeItem = items.find( 106 + (item) => item.kind === "file" && item.decrypted && /^readme\.md$/i.test(item.name), 107 + ); 108 + 109 + const FileListComponent = viewMode === "list" ? FileListRow : FileGridCard; 110 + const fileList = items.map((item) => ( 111 + <FileListComponent 112 + key={item.id} 113 + item={item} 114 + isActive={item.uri === activeUri} 115 + onClick={() => handleItemClick(item)} 116 + onPreview={previewHandler(item)} 117 + onEditMetadata={() => metadataDialogRef.current?.show(item)} 118 + onRename={() => renameDialogRef.current?.show(item.uri, item.name)} 119 + onMove={() => void handleMoveClick(item)} 120 + onShare={() => shareDialogRef.current?.show(item.uri, item.name)} 121 + onDownload={() => onDownload(item.uri)} 122 + onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 123 + onDeleteFolder={() => void handleDeleteFolderClick(item)} 124 + /> 125 + )); 126 + 91 127 return ( 92 128 <div className="p-3"> 93 - {viewMode === "list" ? ( 94 - <div className="flex flex-col gap-px"> 95 - {items.map((item) => ( 96 - <FileListRow 97 - key={item.id} 98 - item={item} 99 - onClick={() => item.kind === "folder" && onOpen(item)} 100 - onEditMetadata={() => metadataDialogRef.current?.show(item)} 101 - onRename={() => renameDialogRef.current?.show(item.uri, item.name)} 102 - onMove={() => void handleMoveClick(item)} 103 - onShare={() => shareDialogRef.current?.show(item.uri, item.name)} 104 - onDownload={() => onDownload(item.uri)} 105 - onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 106 - onDeleteFolder={() => void handleDeleteFolderClick(item)} 107 - /> 108 - ))} 129 + {readmeItem && ( 130 + <div className="mb-3"> 131 + <Suspense key={readmeItem.uri} fallback={<DirectoryReadmeSkeleton />}> 132 + <DirectoryReadme documentUri={readmeItem.uri} /> 133 + </Suspense> 109 134 </div> 135 + )} 136 + 137 + {viewMode === "list" ? ( 138 + <div className="flex flex-col gap-px">{fileList}</div> 110 139 ) : ( 111 - <div className="grid grid-cols-2 gap-3"> 112 - {items.map((item) => ( 113 - <FileGridCard 114 - key={item.id} 115 - item={item} 116 - onClick={() => item.kind === "folder" && onOpen(item)} 117 - onEditMetadata={() => metadataDialogRef.current?.show(item)} 118 - onRename={() => renameDialogRef.current?.show(item.uri, item.name)} 119 - onMove={() => void handleMoveClick(item)} 120 - onShare={() => shareDialogRef.current?.show(item.uri, item.name)} 121 - onDownload={() => onDownload(item.uri)} 122 - onDelete={() => deleteDialogRef.current?.show(item.uri, item.name)} 123 - onDeleteFolder={() => void handleDeleteFolderClick(item)} 124 - /> 125 - ))} 126 - </div> 140 + <div className="grid grid-cols-2 gap-3">{fileList}</div> 127 141 )} 128 142 129 143 <DeleteConfirmDialog ref={deleteDialogRef} onConfirm={onDelete} />
+48 -21
web/src/components/cabinet/PanelShell.tsx
··· 7 7 readonly toolbar?: ReactNode; 8 8 readonly footer: string; 9 9 readonly children: ReactNode; 10 + /** When set, a second panel renders alongside the main one. */ 11 + readonly sidePanel?: ReactNode; 10 12 } 11 13 12 - export function PanelShell({ depth, breadcrumbs, toolbar, footer, children }: PanelShellProps) { 14 + const PANEL_CHROME = 15 + "border-base-300/50 bg-base-100 shadow-panel-lg flex flex-col overflow-hidden rounded-2xl border"; 16 + 17 + export function PanelShell({ 18 + depth, 19 + breadcrumbs, 20 + toolbar, 21 + footer, 22 + children, 23 + sidePanel, 24 + }: PanelShellProps) { 25 + const mainPanel = ( 26 + <div 27 + className={`${PANEL_CHROME} ${sidePanel ? "order-2 min-h-0 flex-1 lg:order-1 lg:basis-2/5" : "h-full"}`} 28 + > 29 + {/* Panel header */} 30 + <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 31 + {breadcrumbs} 32 + {toolbar && <div className="flex shrink-0 items-center gap-2">{toolbar}</div>} 33 + </div> 34 + 35 + {/* Panel body */} 36 + <div className="min-h-0 flex-1 overflow-y-auto">{children}</div> 37 + 38 + {/* Panel footer */} 39 + <div className="border-base-300/50 bg-base-100/60 flex shrink-0 items-center gap-2 border-t px-4 py-2.25"> 40 + <ShieldCheckIcon size={11} className="text-primary" /> 41 + <span className="text-caption text-text-faint">{footer}</span> 42 + <div className="flex-1" /> 43 + {depth > 1 && ( 44 + <span className="font-display text-ui text-text-faint italic">{depth} panels deep</span> 45 + )} 46 + </div> 47 + </div> 48 + ); 49 + 13 50 return ( 14 51 <div className="relative flex-1 overflow-hidden p-5.5 pl-7"> 15 52 {/* Ghost panels — filing cabinet depth */} ··· 23 60 <div className="border-base-300/50 bg-bg-ghost-2 shadow-panel-sm animate-ghost-panel absolute inset-y-5.5 right-5.5 left-7 z-2 -translate-x-1.25 -translate-y-1.25 rounded-2xl border" /> 24 61 )} 25 62 26 - {/* Active panel */} 27 - <div className="border-base-300/50 bg-base-100 shadow-panel-lg absolute inset-y-5.5 right-5.5 left-7 z-10 flex flex-col overflow-hidden rounded-2xl border"> 28 - {/* Panel header */} 29 - <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 30 - {breadcrumbs} 31 - {toolbar && <div className="flex shrink-0 items-center gap-2">{toolbar}</div>} 32 - </div> 33 - 34 - {/* Panel body */} 35 - <div className="min-h-0 flex-1 overflow-y-auto">{children}</div> 36 - 37 - {/* Panel footer */} 38 - <div className="border-base-300/50 bg-base-100/60 flex shrink-0 items-center gap-2 border-t px-4 py-2.25"> 39 - <ShieldCheckIcon size={11} className="text-primary" /> 40 - <span className="text-caption text-text-faint">{footer}</span> 41 - <div className="flex-1" /> 42 - {depth > 1 && ( 43 - <span className="font-display text-ui text-text-faint italic">{depth} panels deep</span> 44 - )} 63 + {sidePanel ? ( 64 + /* Two-panel layout: main + side, stacked on mobile, side-by-side on desktop */ 65 + <div className="absolute inset-y-5.5 right-5.5 left-7 z-10 flex flex-col gap-3 lg:flex-row"> 66 + {mainPanel} 67 + {/* Side panel */} 68 + <div className={`${PANEL_CHROME} order-1 min-h-0 flex-1 lg:order-2`}>{sidePanel}</div> 45 69 </div> 46 - </div> 70 + ) : ( 71 + /* Single-panel layout */ 72 + <div className="absolute inset-y-5.5 right-5.5 left-7 z-10">{mainPanel}</div> 73 + )} 47 74 </div> 48 75 ); 49 76 }
+33
web/src/components/cabinet/PreviewPaneHeader.tsx
··· 1 + import { DownloadSimpleIcon, XIcon } from "@phosphor-icons/react"; 2 + 3 + interface PreviewPaneHeaderProps { 4 + readonly documentName: string | null; 5 + readonly onDownload: () => void; 6 + readonly onClose: () => void; 7 + } 8 + 9 + export function PreviewPaneHeader({ documentName, onDownload, onClose }: PreviewPaneHeaderProps) { 10 + return ( 11 + <div className="border-base-300/50 flex shrink-0 items-center gap-2 border-b px-4 py-2"> 12 + {documentName ? ( 13 + <span className="text-ui text-base-content min-w-0 flex-1 truncate">{documentName}</span> 14 + ) : ( 15 + <div className="skeleton h-4 w-32 flex-1 rounded" /> 16 + )} 17 + <button 18 + onClick={onDownload} 19 + className="btn btn-ghost btn-xs btn-square rounded-md" 20 + aria-label="Download file" 21 + > 22 + <DownloadSimpleIcon size={13} className="text-text-muted" /> 23 + </button> 24 + <button 25 + onClick={onClose} 26 + className="btn btn-ghost btn-xs btn-square rounded-md" 27 + aria-label="Close preview" 28 + > 29 + <XIcon size={13} className="text-text-muted" /> 30 + </button> 31 + </div> 32 + ); 33 + }
+1 -21
web/src/components/cabinet/Sidebar.tsx
··· 1 - import { 2 - FolderIcon, 3 - LockIcon, 4 - UsersIcon, 5 - BookOpenIcon, 6 - TrashIcon, 7 - GearIcon, 8 - } from "@phosphor-icons/react"; 1 + import { FolderIcon, UsersIcon, BookOpenIcon, GearIcon } from "@phosphor-icons/react"; 9 2 import { Link } from "@tanstack/react-router"; 10 3 import { OpakeLogo } from "../OpakeLogo"; 11 4 import { useAppStore } from "@/stores/app"; ··· 13 6 14 7 const MAIN_NAV = [ 15 8 { to: "/cabinet/files" as const, icon: FolderIcon, label: "Your Cabinet" }, 16 - { to: "/cabinet/encrypted" as const, icon: LockIcon, label: "Encrypted" }, 17 9 { to: "/cabinet/shared" as const, icon: UsersIcon, label: "Shared with me", badge: "4" }, 18 10 ]; 19 11 20 12 const BOTTOM_NAV = [ 21 13 { to: "/cabinet/docs" as const, icon: BookOpenIcon, label: "Docs & Help" }, 22 - { to: "/cabinet/trash" as const, icon: TrashIcon, label: "Trash" }, 23 14 { to: "/cabinet/settings" as const, icon: GearIcon, label: "Settings" }, 24 15 ]; 25 16 ··· 39 30 <OpakeLogo loading={anyLoading} /> 40 31 </Link> 41 32 </div> 42 - 43 - {/* Storage */} 44 - <div className="mb-5 px-1"> 45 - <div className="text-caption text-text-faint mb-1.5 flex justify-between"> 46 - <span>Storage</span> 47 - <span>3.1 / 10 GB</span> 48 - </div> 49 - <progress className="progress progress-primary h-0.75 w-full" value={31} max={100} /> 50 - </div> 51 - 52 - <div className="divider mx-1 my-0" /> 53 33 54 34 {/* Main nav */} 55 35 <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto">
+13 -47
web/src/components/cabinet/TopBar.tsx
··· 3 3 import { 4 4 MagnifyingGlassIcon, 5 5 XIcon, 6 - ShieldCheckIcon, 7 6 BellIcon, 8 - UserIcon, 9 - LockIcon, 7 + KeyIcon, 10 8 GearIcon, 11 9 SignOutIcon, 12 10 } from "@phosphor-icons/react"; 13 11 import { Link } from "@tanstack/react-router"; 12 + import { DropdownMenu } from "@/components/DropdownMenu"; 14 13 import { useAuthStore } from "@/stores/auth"; 15 14 import { truncateDid } from "@/lib/format"; 16 15 17 16 interface TopBarProps { 18 17 readonly searchQuery: string; 19 18 readonly onSearchChange: (query: string) => void; 20 - } 21 - 22 - /** Fetch the user's Bluesky profile avatar URL from their PDS. */ 23 - function useAvatarUrl(pdsUrl: string | null, did: string | null): string | null { 24 - const [avatarUrl, setAvatarUrl] = useState<string | null>(null); 25 - 26 - useEffect(() => { 27 - if (!pdsUrl || !did) return; 28 - 29 - const controller = new AbortController(); 30 - 31 - fetch(`${pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, { 32 - signal: controller.signal, 33 - }) 34 - .then((res) => (res.ok ? (res.json() as Promise<{ avatar?: string }>) : null)) 35 - .then((profile) => setAvatarUrl(profile?.avatar ?? null)) 36 - .catch(() => setAvatarUrl(null)); 37 - 38 - return () => { 39 - controller.abort(); 40 - setAvatarUrl(null); 41 - }; 42 - }, [pdsUrl, did]); 43 - 44 - return avatarUrl; 45 19 } 46 20 47 21 export function TopBar({ searchQuery, onSearchChange }: TopBarProps) { ··· 51 25 52 26 const handle = session.status === "active" ? session.handle : null; 53 27 const did = session.status === "active" ? session.did : null; 54 - const pdsUrl = session.status === "active" ? session.pdsUrl : null; 28 + const avatarUrl = session.status === "active" ? session.avatarUrl : null; 55 29 const initial = handle?.[0]?.toUpperCase() ?? "?"; 56 - const avatarUrl = useAvatarUrl(pdsUrl, did); 57 30 58 31 const [menuOpen, setMenuOpen] = useState(false); 59 32 const triggerRef = useRef<HTMLButtonElement>(null); ··· 98 71 99 72 <div className="flex-1" /> 100 73 101 - {/* E2E badge */} 102 - <div className="badge badge-outline border-border-accent bg-accent text-caption text-primary gap-1.5 py-3"> 103 - <ShieldCheckIcon size={12} weight="bold" /> 104 - End-to-end encrypted 105 - </div> 106 - 107 74 {/* Notifications */} 108 - <div className="indicator"> 109 - <span className="indicator-item badge badge-primary badge-xs size-1.5 p-0" /> 110 - <button className="btn btn-ghost btn-sm btn-square rounded-lg"> 111 - <BellIcon size={15} className="text-text-muted" /> 112 - </button> 113 - </div> 75 + <DropdownMenu 76 + trigger={<BellIcon size={15} className="text-text-muted" />} 77 + triggerClassName="btn btn-ghost btn-sm btn-square rounded-lg" 78 + items={[]} 79 + emptyLabel="No notifications" 80 + /> 114 81 115 82 {/* User menu */} 116 83 <button ··· 141 108 createPortal( 142 109 <div 143 110 ref={popoverRef} 144 - className="border-base-300/50 bg-base-100 shadow-panel-lg fixed top-12 right-4 z-[9999] w-52.5 rounded-xl border" 111 + className="border-base-300/50 bg-base-100 shadow-panel-lg fixed top-12 right-4 z-9999 w-52.5 rounded-xl border" 145 112 > 146 113 {handle && did && ( 147 114 <div className="border-base-300/50 border-b px-3.5 py-2.5"> ··· 149 116 <div className="text-caption text-text-faint mt-0.5">{truncateDid(did)}</div> 150 117 </div> 151 118 )} 152 - <ul className="menu p-1"> 119 + <ul className="menu w-full p-1"> 153 120 {[ 154 - { icon: UserIcon, label: "Profile & DID", to: "/cabinet/settings" as const }, 155 - { icon: LockIcon, label: "Encryption Keys", to: "/cabinet/settings" as const }, 121 + { icon: KeyIcon, label: "Encryption Keys", to: "/devices" as const }, 156 122 { icon: GearIcon, label: "Settings", to: "/cabinet/settings" as const }, 157 123 ].map(({ icon: Icon, label, to }) => ( 158 124 <li key={label}> ··· 164 130 ))} 165 131 </ul> 166 132 <div className="divider my-0.5" /> 167 - <ul className="menu p-1 pt-0"> 133 + <ul className="menu w-full p-1 pt-0"> 168 134 <li> 169 135 <button 170 136 onClick={() => {
+12
web/src/components/cabinet/types.ts
··· 18 18 mimeType?: string; 19 19 description?: string; 20 20 } 21 + 22 + const PREVIEWABLE_FILE_TYPES: ReadonlySet<FileType> = new Set(["image", "note"]); 23 + 24 + /** Whether a file item can be previewed inline (images, markdown). */ 25 + export function isPreviewable(item: FileItem): boolean { 26 + return ( 27 + item.kind === "file" && 28 + item.decrypted && 29 + !!item.fileType && 30 + PREVIEWABLE_FILE_TYPES.has(item.fileType) 31 + ); 32 + }
+5
web/src/lib/atUri.ts
··· 8 8 export function directoryUri(did: string, rkey: string): string { 9 9 return `at://${did}/app.opake.directory/${rkey}`; 10 10 } 11 + 12 + /** Build a full AT URI for a document record. */ 13 + export function documentUri(did: string, rkey: string): string { 14 + return `at://${did}/app.opake.document/${rkey}`; 15 + }
+9 -30
web/src/lib/download.ts
··· 1 - // Download orchestration — fetch encrypted blob, decrypt client-side, trigger browser save. 1 + // Download orchestration — decrypt blob client-side, trigger browser save. 2 2 3 - import { authenticatedBlobFetch } from "@/lib/api"; 4 - import { base64ToUint8Array } from "@/lib/encoding"; 5 - import { getCryptoWorker } from "@/lib/worker"; 6 - import { unwrapDirectContentKey, decryptEnvelope } from "@/stores/documents/decrypt"; 7 - import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 3 + import { decryptDocumentBlob } from "@/lib/preview"; 4 + import type { PdsRecord, DocumentRecord } from "@/lib/pdsTypes"; 8 5 import type { Session } from "@/lib/storageTypes"; 9 6 10 7 export async function downloadDocument( ··· 14 11 privateKey: Uint8Array, 15 12 session: Session, 16 13 ): Promise<void> { 17 - const { encryption } = record.value; 18 - if (encryption.$type !== "app.opake.document#directEncryption") { 19 - throw new Error("Keyring-encrypted downloads are not yet supported"); 20 - } 21 - 22 - const contentKey = await unwrapDirectContentKey(encryption, did, privateKey); 23 - 24 - // Fetch encrypted blob from PDS 25 - const cid = record.value.blob.ref.$link; 26 - const encryptedBlob = await authenticatedBlobFetch({ pdsUrl, did, cid }, session); 27 - 28 - // Decrypt the blob 29 - const worker = getCryptoWorker(); 30 - const blobNonce = base64ToUint8Array(encryption.envelope.nonce.$bytes); 31 - const plaintext = await worker.decryptBlob(contentKey, new Uint8Array(encryptedBlob), blobNonce); 32 - 33 - // Decrypt metadata for filename 34 - const { ciphertext: metaCiphertext, nonce: metaNonce } = decryptEnvelope( 35 - record.value.encryptedMetadata, 36 - ); 37 - const metadata: DocumentMetadata = await worker.decryptMetadata( 38 - contentKey, 39 - metaCiphertext, 40 - metaNonce, 14 + const { plaintext, metadata } = await decryptDocumentBlob( 15 + record, 16 + pdsUrl, 17 + did, 18 + privateKey, 19 + session, 41 20 ); 42 21 43 22 const filename = metadata.name;
+59
web/src/lib/preview.ts
··· 1 + // Shared decrypt-without-download logic for file previews and downloads. 2 + 3 + import { authenticatedBlobFetch } from "@/lib/api"; 4 + import { base64ToUint8Array } from "@/lib/encoding"; 5 + import { getCryptoWorker } from "@/lib/worker"; 6 + import { unwrapDirectContentKey, decryptEnvelope } from "@/stores/documents/decrypt"; 7 + import type { PdsRecord, DocumentRecord, DocumentMetadata } from "@/lib/pdsTypes"; 8 + import type { Session } from "@/lib/storageTypes"; 9 + 10 + export interface DecryptedBlob { 11 + readonly plaintext: Uint8Array; 12 + readonly metadata: DocumentMetadata; 13 + } 14 + 15 + /** 16 + * Decrypt a document's blob content. If `knownMetadata` is provided (e.g. from 17 + * the store), the expensive metadata decryption step is skipped. 18 + */ 19 + export async function decryptDocumentBlob( 20 + record: PdsRecord<DocumentRecord>, 21 + pdsUrl: string, 22 + did: string, 23 + privateKey: Uint8Array, 24 + session: Session, 25 + knownMetadata?: DocumentMetadata, 26 + ): Promise<DecryptedBlob> { 27 + const { encryption } = record.value; 28 + if (encryption.$type !== "app.opake.document#directEncryption") { 29 + throw new Error("Keyring-encrypted documents are not yet supported"); 30 + } 31 + 32 + const cid = record.value.blob.ref.$link; 33 + 34 + // Key unwrap and blob fetch are independent — run in parallel 35 + const [contentKey, encryptedBlob] = await Promise.all([ 36 + unwrapDirectContentKey(encryption, did, privateKey), 37 + authenticatedBlobFetch({ pdsUrl, did, cid }, session), 38 + ]); 39 + 40 + const worker = getCryptoWorker(); 41 + const blobNonce = base64ToUint8Array(encryption.envelope.nonce.$bytes); 42 + const plaintext = await worker.decryptBlob(contentKey, new Uint8Array(encryptedBlob), blobNonce); 43 + 44 + // Skip metadata decryption if caller already has it from the store 45 + if (knownMetadata) { 46 + return { plaintext, metadata: knownMetadata }; 47 + } 48 + 49 + const { ciphertext: metaCiphertext, nonce: metaNonce } = decryptEnvelope( 50 + record.value.encryptedMetadata, 51 + ); 52 + const metadata: DocumentMetadata = await worker.decryptMetadata( 53 + contentKey, 54 + metaCiphertext, 55 + metaNonce, 56 + ); 57 + 58 + return { plaintext, metadata }; 59 + }
+67
web/src/lib/resizeImage.ts
··· 1 + // Canvas-based image resize — caps to a max dimension, returns a blob URL. 2 + // SVGs are skipped (vector, no resize needed). 3 + 4 + const MAX_DIMENSION = 2000; 5 + 6 + export async function resizeImageBlob( 7 + data: Uint8Array, 8 + mimeType: string, 9 + maxDimension: number = MAX_DIMENSION, 10 + ): Promise<string> { 11 + const buffer = new ArrayBuffer(data.byteLength); 12 + new Uint8Array(buffer).set(data); 13 + const sourceUrl = URL.createObjectURL(new Blob([buffer], { type: mimeType })); 14 + 15 + // Vector images don't need rasterized resizing 16 + if (mimeType === "image/svg+xml") return sourceUrl; 17 + 18 + try { 19 + const image = await loadImage(sourceUrl); 20 + const { width, height } = image; 21 + 22 + // Already within bounds — return the source blob URL directly 23 + if (width <= maxDimension && height <= maxDimension) return sourceUrl; 24 + 25 + const scale = maxDimension / Math.max(width, height); 26 + const targetWidth = Math.round(width * scale); 27 + const targetHeight = Math.round(height * scale); 28 + 29 + const canvas = document.createElement("canvas"); 30 + // eslint-disable-next-line functional/immutable-data -- canvas setup 31 + canvas.width = targetWidth; 32 + // eslint-disable-next-line functional/immutable-data -- canvas setup 33 + canvas.height = targetHeight; 34 + 35 + const ctx = canvas.getContext("2d"); 36 + if (!ctx) throw new Error("Canvas 2D context unavailable"); 37 + 38 + ctx.drawImage(image, 0, 0, targetWidth, targetHeight); 39 + 40 + const resizedBlob = await new Promise<Blob>((resolve, reject) => { 41 + canvas.toBlob( 42 + (blob) => (blob ? resolve(blob) : reject(new Error("Canvas toBlob failed"))), 43 + mimeType, 44 + 0.92, 45 + ); 46 + }); 47 + 48 + // Release the original blob URL — we have a resized one now 49 + URL.revokeObjectURL(sourceUrl); 50 + return URL.createObjectURL(resizedBlob); 51 + } catch { 52 + // Fallback: return the original blob URL if resize fails 53 + return sourceUrl; 54 + } 55 + } 56 + 57 + function loadImage(src: string): Promise<HTMLImageElement> { 58 + return new Promise((resolve, reject) => { 59 + const img = new Image(); 60 + // eslint-disable-next-line functional/immutable-data -- event handler setup 61 + img.onload = () => resolve(img); 62 + // eslint-disable-next-line functional/immutable-data -- event handler setup 63 + img.onerror = () => reject(new Error("Failed to load image")); 64 + // eslint-disable-next-line functional/immutable-data -- property assignment 65 + img.src = src; 66 + }); 67 + }
+54 -9
web/src/routes/cabinet/files/$.tsx
··· 1 - import { useEffect } from "react"; 1 + import { useEffect, useRef } from "react"; 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { useShallow } from "zustand/react/shallow"; 4 4 import { PanelContent } from "@/components/cabinet/PanelContent"; 5 + import { evictPreviewCache } from "@/components/cabinet/FilePreview"; 6 + import { evictReadmeCache } from "@/components/cabinet/DirectoryReadme"; 5 7 import { useDocumentsStore } from "@/stores/documents"; 6 8 import { useAuthStore } from "@/stores/auth"; 7 - import { directoryUri, rkeyFromUri } from "@/lib/atUri"; 9 + import { directoryUri, documentUri, rkeyFromUri } from "@/lib/atUri"; 8 10 import type { FileItem } from "@/components/cabinet/types"; 9 11 10 12 function SubdirectoryContent() { 11 13 const navigate = useNavigate(); 12 14 const { _splat: splat = "" } = Route.useParams(); 13 15 const segments = splat.split("/").filter(Boolean); 14 - const currentRkey = segments[segments.length - 1]; 16 + const lastRkey = segments[segments.length - 1]; 15 17 16 18 const session = useAuthStore((s) => s.session); 17 19 const did = session.status === "active" ? session.did : null; 18 - const currentDirectoryUri = did && currentRkey ? directoryUri(did, currentRkey) : null; 20 + 21 + // Determine whether the last segment is a directory or a document 22 + const lastDirectoryUri = did && lastRkey ? directoryUri(did, lastRkey) : null; 23 + const lastDocumentUri = did && lastRkey ? documentUri(did, lastRkey) : null; 19 24 25 + const treeSnapshot = useDocumentsStore((s) => s.treeSnapshot); 26 + const documentRecords = useDocumentsStore((s) => s.documentRecords); 20 27 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 21 28 const viewMode = useDocumentsStore((s) => s.viewMode); 22 29 const downloadFile = useDocumentsStore((s) => s.downloadFile); ··· 25 32 const updateMetadata = useDocumentsStore((s) => s.updateMetadata); 26 33 const moveEntry = useDocumentsStore((s) => s.moveEntry); 27 34 const renameDirectory = useDocumentsStore((s) => s.renameDirectory); 35 + 36 + const isLastDirectory = !!(lastDirectoryUri && treeSnapshot?.directories[lastDirectoryUri]); 37 + const isLastDocument = !!(lastDocumentUri && documentRecords[lastDocumentUri]); 38 + const isPreview = !isLastDirectory && isLastDocument; 39 + 40 + // Resolve directory context: parent directory when previewing, current when browsing 41 + const dirSegments = isPreview ? segments.slice(0, -1) : segments; 42 + const dirRkey = dirSegments.length > 0 ? dirSegments[dirSegments.length - 1] : undefined; 43 + const currentDirectoryUri = did && dirRkey ? directoryUri(did, dirRkey) : null; 44 + 28 45 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 29 46 47 + const readmeUriRef = useRef<string | null>(null); 48 + const readmeItem = items.find( 49 + (item) => item.kind === "file" && item.decrypted && /^readme\.md$/i.test(item.name), 50 + ); 51 + const readmeUri = readmeItem?.uri ?? null; 52 + 53 + // Evict README cache only when the readme URI changes (not on unmount, 54 + // since opening a preview re-renders but keeps the same directory context) 30 55 useEffect(() => { 31 - if (currentDirectoryUri) { 32 - void ensureDirectoryDecrypted(currentDirectoryUri); 56 + if (readmeUriRef.current && readmeUriRef.current !== readmeUri) { 57 + evictReadmeCache(readmeUriRef.current); 33 58 } 59 + readmeUriRef.current = readmeUri; 60 + }, [readmeUri]); 61 + 62 + // Decrypt the resolved directory — works for both directory browsing and preview mode. 63 + // Fixes the breadcrumb bug: on direct navigation to a preview URL, the parent 64 + // directory's documents get decrypted, populating the file list and names. 65 + useEffect(() => { 66 + void ensureDirectoryDecrypted(currentDirectoryUri); 34 67 }, [currentDirectoryUri, ensureDirectoryDecrypted]); 35 68 36 - const handleOpen = (item: FileItem) => { 69 + // Evict decrypted blob from cache when navigating away from a preview 70 + useEffect(() => { 71 + if (!isPreview || !lastDocumentUri) return undefined; 72 + return () => evictPreviewCache(lastDocumentUri); 73 + }, [isPreview, lastDocumentUri]); 74 + 75 + // Navigation uses the directory segments as the base, so clicking a file 76 + // while previewing replaces the document rkey instead of appending. 77 + const baseSplat = dirSegments.join("/"); 78 + 79 + const navigateToChild = (item: FileItem) => { 37 80 const childRkey = rkeyFromUri(item.uri); 38 81 void navigate({ 39 82 to: "/cabinet/files/$", 40 - params: { _splat: `${splat}/${childRkey}` }, 83 + params: { _splat: baseSplat ? `${baseSplat}/${childRkey}` : childRkey }, 41 84 }); 42 85 }; 43 86 ··· 45 88 <PanelContent 46 89 items={items} 47 90 viewMode={viewMode} 48 - onOpen={handleOpen} 91 + activeUri={isPreview && lastDocumentUri ? lastDocumentUri : undefined} 92 + onOpen={navigateToChild} 93 + onPreview={navigateToChild} 49 94 onDownload={(uri) => void downloadFile(uri)} 50 95 onDelete={(uri) => void deleteFile(uri)} 51 96 onDeleteFolder={(uri) => void deleteFolder(uri)}
+20 -3
web/src/routes/cabinet/files/index.tsx
··· 1 - import { useEffect } from "react"; 1 + import { useEffect, useRef } from "react"; 2 2 import { createFileRoute, useNavigate } from "@tanstack/react-router"; 3 3 import { useShallow } from "zustand/react/shallow"; 4 4 import { PanelContent } from "@/components/cabinet/PanelContent"; 5 + import { evictReadmeCache } from "@/components/cabinet/DirectoryReadme"; 5 6 import { useDocumentsStore } from "@/stores/documents"; 6 7 import { rkeyFromUri } from "@/lib/atUri"; 7 8 import type { FileItem } from "@/components/cabinet/types"; ··· 18 19 const renameDirectory = useDocumentsStore((s) => s.renameDirectory); 19 20 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 20 21 22 + const readmeUriRef = useRef<string | null>(null); 23 + const readmeItem = items.find( 24 + (item) => item.kind === "file" && item.decrypted && /^readme\.md$/i.test(item.name), 25 + ); 26 + const readmeUri = readmeItem?.uri ?? null; 27 + 28 + // Evict README cache only when the readme URI changes (not on unmount, 29 + // since opening a preview unmounts this component but keeps the same directory) 30 + useEffect(() => { 31 + if (readmeUriRef.current && readmeUriRef.current !== readmeUri) { 32 + evictReadmeCache(readmeUriRef.current); 33 + } 34 + readmeUriRef.current = readmeUri; 35 + }, [readmeUri]); 36 + 21 37 useEffect(() => { 22 38 void ensureDirectoryDecrypted(null); 23 39 }, [ensureDirectoryDecrypted]); 24 40 25 - const handleOpen = (item: FileItem) => { 41 + const navigateToChild = (item: FileItem) => { 26 42 void navigate({ 27 43 to: "/cabinet/files/$", 28 44 params: { _splat: rkeyFromUri(item.uri) }, ··· 33 49 <PanelContent 34 50 items={items} 35 51 viewMode={viewMode} 36 - onOpen={handleOpen} 52 + onOpen={navigateToChild} 53 + onPreview={navigateToChild} 37 54 onDownload={(uri) => void downloadFile(uri)} 38 55 onDelete={(uri) => void deleteFile(uri)} 39 56 onDeleteFolder={(uri) => void deleteFolder(uri)}
+166 -76
web/src/routes/cabinet/files/route.tsx
··· 1 - import { useEffect, useRef } from "react"; 1 + import { Suspense, useEffect, useRef } from "react"; 2 2 import { createFileRoute, Link, Outlet, useMatch, useNavigate } from "@tanstack/react-router"; 3 3 import { 4 4 ListBulletsIcon, ··· 19 19 } from "@/components/cabinet/Breadcrumbs"; 20 20 import { PanelShell } from "@/components/cabinet/PanelShell"; 21 21 import { PanelSkeleton } from "@/components/cabinet/PanelSkeleton"; 22 + import { PreviewPaneHeader } from "@/components/cabinet/PreviewPaneHeader"; 23 + import { FilePreview, evictPreviewCache } from "@/components/cabinet/FilePreview"; 22 24 import { TagFilterBar } from "@/components/cabinet/TagFilterBar"; 23 25 import { NewFolderDialog, type NewFolderDialogHandle } from "@/components/cabinet/NewFolderDialog"; 24 26 import { useDocumentsStore } from "@/stores/documents"; 25 27 import { useAuthStore } from "@/stores/auth"; 26 28 import { useAppStore } from "@/stores/app"; 27 - import { directoryUri } from "@/lib/atUri"; 29 + import { directoryUri, documentUri } from "@/lib/atUri"; 30 + import type { DirectoryTreeSnapshot } from "@/lib/pdsTypes"; 31 + import type { FileItem } from "@/components/cabinet/types"; 32 + 33 + // --------------------------------------------------------------------------- 34 + // Derived state helpers 35 + // --------------------------------------------------------------------------- 36 + 37 + function computeAvailableTags( 38 + treeSnapshot: DirectoryTreeSnapshot | null, 39 + contextDirectoryUri: string | null, 40 + items: Readonly<Record<string, FileItem>>, 41 + ): readonly string[] { 42 + if (!treeSnapshot) return []; 43 + const targetUri = contextDirectoryUri ?? treeSnapshot.rootUri; 44 + if (!targetUri) return []; 45 + const dirEntry = treeSnapshot.directories[targetUri]; 46 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 47 + if (!dirEntry) return []; 48 + const tags = new Set( 49 + dirEntry.entries 50 + .map((uri) => items[uri]) 51 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: items may not be populated yet 52 + .filter((item): item is NonNullable<typeof item> => item != null) 53 + .flatMap((item) => item.tags), 54 + ); 55 + return [...tags].sort((a, b) => a.localeCompare(b)); 56 + } 28 57 58 + function computeFooterText( 59 + contextDirectoryUri: string | null, 60 + contextRkey: string | undefined, 61 + treeSnapshot: DirectoryTreeSnapshot | null, 62 + ): string { 63 + const targetUri = contextDirectoryUri ?? treeSnapshot?.rootUri; 64 + if (!targetUri || !treeSnapshot) return "Loading\u2026"; 65 + const dirEntry = treeSnapshot.directories[targetUri]; 66 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 67 + if (!dirEntry) return "Encrypted"; 68 + return contextRkey 69 + ? `${dirEntry.entries.length} items \u00b7 Encrypted` 70 + : `${dirEntry.entries.length} items \u00b7 All encrypted \u00b7 AT Protocol`; 71 + } 72 + 73 + // --------------------------------------------------------------------------- 74 + // Component 75 + // --------------------------------------------------------------------------- 76 + 77 + // eslint-disable-next-line sonarjs/cognitive-complexity -- layout component with split-panel preview; splitting further would obscure the routing logic 29 78 function FileBrowserLayout() { 30 79 const navigate = useNavigate(); 31 80 const fileInputRef = useRef<HTMLInputElement>(null); 32 81 const newFolderDialogRef = useRef<NewFolderDialogHandle>(null); 33 82 const uploadFile = useDocumentsStore((s) => s.uploadFile); 34 83 const createFolder = useDocumentsStore((s) => s.createFolder); 84 + const downloadFile = useDocumentsStore((s) => s.downloadFile); 35 85 36 86 // Determine current directory from child splat route params 37 87 const splatMatch = useMatch({ ··· 45 95 const session = useAuthStore((s) => s.session); 46 96 const did = session.status === "active" ? session.did : null; 47 97 const currentDirectoryUri = rkey && did ? directoryUri(did, rkey) : null; 98 + const currentDocumentUri = rkey && did ? documentUri(did, rkey) : null; 48 99 49 100 const documentsLoading = useAppStore((s) => s.isLoading("documents-fetch")); 50 101 const viewMode = useDocumentsStore((s) => s.viewMode); ··· 52 103 const ancestorsOf = useDocumentsStore((s) => s.ancestorsOf); 53 104 const items = useDocumentsStore((s) => s.items); 54 105 const treeSnapshot = useDocumentsStore((s) => s.treeSnapshot); 106 + const documentRecords = useDocumentsStore((s) => s.documentRecords); 55 107 const activeTagFilters = useDocumentsStore((s) => s.activeTagFilters); 56 108 const setTagFilters = useDocumentsStore((s) => s.setTagFilters); 57 109 58 - // Derived values — computed during render, not inside selectors 59 - const ancestors = ancestorsOf(currentDirectoryUri); 110 + // Detect whether the last segment is a document (preview mode) 111 + const isDirectory = !!(currentDirectoryUri && treeSnapshot?.directories[currentDirectoryUri]); 112 + const isDocument = !!(currentDocumentUri && documentRecords[currentDocumentUri]); 113 + const isPreviewMode = rkey != null && !isDirectory && isDocument; 114 + 115 + // In preview mode, the directory context is the parent 116 + const parentSegments = isPreviewMode ? segments.slice(0, -1) : segments; 117 + const parentRkey = 118 + parentSegments.length > 0 ? parentSegments[parentSegments.length - 1] : undefined; 119 + const parentDirectoryUri = parentRkey && did ? directoryUri(did, parentRkey) : null; 120 + 121 + // Effective directory context: parent when previewing, current when browsing 122 + const contextDirectoryUri = isPreviewMode ? parentDirectoryUri : currentDirectoryUri; 123 + const contextRkey = isPreviewMode ? parentRkey : rkey; 60 124 61 - const currentDirectoryItem = currentDirectoryUri ? items[currentDirectoryUri] : undefined; 125 + const ancestors = ancestorsOf(contextDirectoryUri); 126 + const currentDirectoryItem = contextDirectoryUri ? items[contextDirectoryUri] : undefined; 62 127 const currentDirectoryName = currentDirectoryItem?.name ?? null; 63 128 64 - const depth = segments.length > 0 ? segments.length + 1 : 1; 129 + // Preview document name for the preview pane header 130 + const previewDocumentItem = 131 + isPreviewMode && currentDocumentUri ? items[currentDocumentUri] : undefined; 132 + const previewDocumentName = previewDocumentItem?.name ?? null; 65 133 66 - // Tags scoped to the current directory (unfiltered — shows all tags, not just active) 67 - const availableTags = (() => { 68 - if (!treeSnapshot) return []; 69 - const targetUri = currentDirectoryUri ?? treeSnapshot.rootUri; 70 - if (!targetUri) return []; 71 - const dirEntry = treeSnapshot.directories[targetUri]; 72 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 73 - if (!dirEntry) return []; 74 - const tags = new Set( 75 - dirEntry.entries 76 - .map((uri) => items[uri]) 77 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard: items may not be populated yet 78 - .filter((item): item is NonNullable<typeof item> => item != null) 79 - .flatMap((item) => item.tags), 80 - ); 81 - return [...tags].sort((a, b) => a.localeCompare(b)); 82 - })(); 134 + // Depth is based on directory segments only — preview doesn't add depth 135 + const effectiveSegments = isPreviewMode ? parentSegments : segments; 136 + const depth = effectiveSegments.length > 0 ? effectiveSegments.length + 1 : 1; 137 + 138 + const availableTags = computeAvailableTags(treeSnapshot, contextDirectoryUri, items); 139 + const footerText = computeFooterText(contextDirectoryUri, contextRkey, treeSnapshot); 83 140 84 - // Clear stale tag filters when navigating to a different directory 85 141 useEffect(() => { 86 142 setTagFilters([]); 87 - }, [currentDirectoryUri, setTagFilters]); 143 + }, [contextDirectoryUri, setTagFilters]); 88 144 89 - const footerText = (() => { 90 - const targetUri = currentDirectoryUri ?? treeSnapshot?.rootUri; 91 - if (!targetUri || !treeSnapshot) return "Loading\u2026"; 92 - const dirEntry = treeSnapshot.directories[targetUri]; 93 - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime guard 94 - if (!dirEntry) return "Encrypted"; 95 - return rkey 96 - ? `${dirEntry.entries.length} items \u00b7 Encrypted` 97 - : `${dirEntry.entries.length} items \u00b7 All encrypted \u00b7 AT Protocol`; 98 - })(); 145 + // Evict preview cache when leaving preview mode 146 + useEffect(() => { 147 + if (!isPreviewMode || !currentDocumentUri) return undefined; 148 + return () => evictPreviewCache(currentDocumentUri); 149 + }, [isPreviewMode, currentDocumentUri]); 99 150 100 151 const handleToggleTag = (tag: string) => { 101 152 const current = [...activeTagFilters]; ··· 111 162 const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => { 112 163 const file = e.target.files?.[0]; 113 164 if (!file) return; 114 - void uploadFile(file, currentDirectoryUri); 115 - // Reset so re-selecting the same file triggers onChange again 165 + void uploadFile(file, contextDirectoryUri); 116 166 e.target.value = ""; 117 167 }; 118 168 169 + // Panel close: navigate up from the directory context 119 170 const handleClose = () => { 120 - if (segments.length > 1) { 171 + if (effectiveSegments.length > 1) { 172 + void navigate({ 173 + to: "/cabinet/files/$", 174 + params: { _splat: effectiveSegments.slice(0, -1).join("/") }, 175 + }); 176 + } else { 177 + void navigate({ to: "/cabinet/files" }); 178 + } 179 + }; 180 + 181 + // Preview close: dismiss the preview, stay in the directory 182 + const handleClosePreview = () => { 183 + if (parentSegments.length > 0) { 121 184 void navigate({ 122 185 to: "/cabinet/files/$", 123 - params: { _splat: segments.slice(0, -1).join("/") }, 186 + params: { _splat: parentSegments.join("/") }, 124 187 }); 125 188 } else { 126 189 void navigate({ to: "/cabinet/files" }); 127 190 } 128 191 }; 129 192 130 - // -- Toolbar -- 193 + // Breadcrumb always shows directory path — document name is in the preview pane header 194 + const breadcrumbSegments = isPreviewMode ? parentSegments : segments; 195 + 196 + const breadcrumbsContent = ( 197 + <Breadcrumbs> 198 + {contextRkey ? ( 199 + <li> 200 + <Link to="/cabinet/files" className="text-text-faint"> 201 + Your Cabinet 202 + </Link> 203 + </li> 204 + ) : ( 205 + <BreadcrumbActive>Your Cabinet</BreadcrumbActive> 206 + )} 207 + {ancestors.map((ancestor, index) => ( 208 + <li key={ancestor.uri}> 209 + <Link 210 + to="/cabinet/files/$" 211 + params={{ _splat: breadcrumbSegments.slice(0, index + 1).join("/") }} 212 + className="text-text-faint" 213 + > 214 + {ancestor.name} 215 + </Link> 216 + </li> 217 + ))} 218 + {contextRkey && currentDirectoryName && ( 219 + <BreadcrumbActive>{currentDirectoryName}</BreadcrumbActive> 220 + )} 221 + {contextRkey && !currentDirectoryName && <BreadcrumbSkeleton />} 222 + </Breadcrumbs> 223 + ); 224 + 131 225 const toolbar = ( 132 226 <> 133 227 <SegmentedToggle ··· 170 264 </> 171 265 ); 172 266 267 + const tagFilterBar = ( 268 + <TagFilterBar 269 + availableTags={availableTags} 270 + activeFilters={activeTagFilters} 271 + onToggle={handleToggleTag} 272 + onClear={() => setTagFilters([])} 273 + /> 274 + ); 275 + 276 + const outletContent = documentsLoading ? <PanelSkeleton /> : <Outlet />; 277 + 278 + const previewPanel = 279 + isPreviewMode && currentDocumentUri ? ( 280 + <> 281 + <PreviewPaneHeader 282 + documentName={previewDocumentName} 283 + onDownload={() => void downloadFile(currentDocumentUri)} 284 + onClose={handleClosePreview} 285 + /> 286 + <div className="min-h-0 flex-1 overflow-hidden"> 287 + <Suspense fallback={<PanelSkeleton />}> 288 + <FilePreview documentUri={currentDocumentUri} /> 289 + </Suspense> 290 + </div> 291 + </> 292 + ) : undefined; 293 + 173 294 return ( 174 295 <PanelShell 175 296 depth={depth} 176 - breadcrumbs={ 177 - <Breadcrumbs> 178 - {rkey ? ( 179 - <li> 180 - <Link to="/cabinet/files" className="text-text-faint"> 181 - Your Cabinet 182 - </Link> 183 - </li> 184 - ) : ( 185 - <BreadcrumbActive>Your Cabinet</BreadcrumbActive> 186 - )} 187 - {ancestors.map((ancestor, index) => ( 188 - <li key={ancestor.uri}> 189 - <Link 190 - to="/cabinet/files/$" 191 - params={{ _splat: segments.slice(0, index + 1).join("/") }} 192 - className="text-text-faint" 193 - > 194 - {ancestor.name} 195 - </Link> 196 - </li> 197 - ))} 198 - {rkey && currentDirectoryName && ( 199 - <BreadcrumbActive>{currentDirectoryName}</BreadcrumbActive> 200 - )} 201 - {rkey && !currentDirectoryName && <BreadcrumbSkeleton />} 202 - </Breadcrumbs> 203 - } 297 + breadcrumbs={breadcrumbsContent} 204 298 toolbar={toolbar} 205 299 footer={footerText} 300 + sidePanel={previewPanel} 206 301 > 207 - <TagFilterBar 208 - availableTags={availableTags} 209 - activeFilters={activeTagFilters} 210 - onToggle={handleToggleTag} 211 - onClear={() => setTagFilters([])} 212 - /> 213 - {documentsLoading ? <PanelSkeleton /> : <Outlet />} 302 + {tagFilterBar} 303 + {outletContent} 214 304 <input 215 305 ref={fileInputRef} 216 306 type="file" ··· 220 310 /> 221 311 <NewFolderDialog 222 312 ref={newFolderDialogRef} 223 - onConfirm={(name) => void createFolder(name, currentDirectoryUri)} 313 + onConfirm={(name) => void createFolder(name, contextDirectoryUri)} 224 314 /> 225 315 </PanelShell> 226 316 );
+47 -3
web/src/stores/auth.ts
··· 37 37 | { status: "initializing" } 38 38 | { status: "none" } 39 39 | { status: "authenticating" } 40 - | { status: "active"; did: string; handle: string; pdsUrl: string } 40 + | { status: "active"; did: string; handle: string; pdsUrl: string; avatarUrl: string | null } 41 41 | { status: "error"; message: string }; 42 42 43 43 export type IdentityState = ··· 77 77 // --------------------------------------------------------------------------- 78 78 // Helpers 79 79 // --------------------------------------------------------------------------- 80 + 81 + /** Fetch the user's Bluesky profile avatar URL via the PDS proxy. */ 82 + async function fetchAvatarUrl(pdsUrl: string, did: string): Promise<string | null> { 83 + try { 84 + const res = await fetch( 85 + `${pdsUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}`, 86 + { headers: { "atproto-proxy": "did:web:api.bsky.app#bsky_appview" } }, 87 + ); 88 + if (!res.ok) return null; 89 + const profile = (await res.json()) as { avatar?: string }; 90 + return profile.avatar ?? null; 91 + } catch { 92 + return null; 93 + } 94 + } 80 95 81 96 /** Check if publicKey/self already exists on the PDS. */ 82 97 async function fetchUpstreamPublicKey( ··· 140 155 await storage.loadSession(did); 141 156 142 157 set((draft) => { 143 - draft.session = { status: "active", did, handle: account.handle, pdsUrl: account.pdsUrl }; 158 + draft.session = { 159 + status: "active", 160 + did, 161 + handle: account.handle, 162 + pdsUrl: account.pdsUrl, 163 + avatarUrl: null, 164 + }; 165 + }); 166 + 167 + // Fire-and-forget — don't block boot on a profile picture 168 + void fetchAvatarUrl(account.pdsUrl, did).then((avatarUrl) => { 169 + set((draft) => { 170 + if (draft.session.status === "active") { 171 + draft.session.avatarUrl = avatarUrl; 172 + } 173 + }); 144 174 }); 145 175 } catch { 146 176 set((draft) => { ··· 360 390 clearPendingState(); 361 391 362 392 set((draft) => { 363 - draft.session = { status: "active", did, handle: pending.handle, pdsUrl: pending.pdsUrl }; 393 + draft.session = { 394 + status: "active", 395 + did, 396 + handle: pending.handle, 397 + pdsUrl: pending.pdsUrl, 398 + avatarUrl: null, 399 + }; 364 400 draft.identity = { status: "unchecked" }; 401 + }); 402 + 403 + void fetchAvatarUrl(pending.pdsUrl, did).then((avatarUrl) => { 404 + set((draft) => { 405 + if (draft.session.status === "active") { 406 + draft.session.avatarUrl = avatarUrl; 407 + } 408 + }); 365 409 }); 366 410 } catch (error) { 367 411 console.error("[auth] completeLogin failed:", error);
+3 -8
web/src/stores/documents/store.ts
··· 102 102 const { did, pdsUrl } = authState.session; 103 103 const done = loading("documents-fetch"); 104 104 105 - set((draft) => { 106 - draft.error = null; 107 - draft.items = {}; 108 - draft.treeSnapshot = null; 109 - draft.documentRecords = {}; 110 - draft.decryptedDirectories = new Set(); 111 - }); 112 - 113 105 try { 114 106 const session = await storage.loadSession(did); 115 107 const identity = await storage.loadIdentity(did); ··· 156 148 const docRecordsMap: Readonly<Record<string, PdsRecord<DocumentRecord>>> = 157 149 Object.fromEntries(documentRecords.map((r) => [r.uri, r] as const)); 158 150 151 + // Swap atomically — no intermediate empty state that causes skeleton flicker 159 152 set((draft) => { 153 + draft.error = null; 160 154 draft.items = items; 161 155 draft.treeSnapshot = castDraft(snapshot); 162 156 draft.documentRecords = castDraft(docRecordsMap); 157 + draft.decryptedDirectories = new Set(); 163 158 }); 164 159 165 160 done();