An encrypted personal cloud built on the AT Protocol.

Centralize loading state via app store and split OpakeLogo [CL-251]

Replace ad-hoc loading boolean and downloadingUris set in documents store
with the shared app store loading() helper. Sidebar logo now animates
during any async operation. Extract OpakeLogoSquares as standalone component.

+137 -132
+1
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 + - Integrate app store loading tracker into document fetching and downloads [#251](https://issues.opake.app/issues/251.html) 15 16 - Add web file download with client-side decryption [#147](https://issues.opake.app/issues/147.html) 16 17 - Add web file browser with tag filtering [#145](https://issues.opake.app/issues/145.html) 17 18 - Add URL-based routing for cabinet directory navigation [#247](https://issues.opake.app/issues/247.html)
+12 -83
web/src/components/OpakeLogo.tsx
··· 1 - import { useEffect, useRef } from "react"; 1 + import { OpakeLogoSquares, type LogoSize } from "./OpakeLogoSquares"; 2 2 3 - const SIZES = { 4 - sm: { square: 16, wrap: 22, text: "text-[0.9rem]" }, 5 - md: { square: 22, wrap: 28, text: "text-[1.1rem]" }, 6 - lg: { square: 30, wrap: 36, text: "text-[1.5rem]" }, 7 - xl: { square: 40, wrap: 48, text: "text-[2rem]" }, 8 - "2xl": { square: 54, wrap: 64, text: "text-[2.75rem]" }, 9 - } as const; 10 - type LogoSize = keyof typeof SIZES; 3 + const TEXT_SIZES: Readonly<Record<LogoSize, string>> = { 4 + sm: "text-[0.9rem]", 5 + md: "text-[1.1rem]", 6 + lg: "text-[1.5rem]", 7 + xl: "text-[2rem]", 8 + "2xl": "text-[2.75rem]", 9 + }; 11 10 12 11 type Props = Readonly<{ 13 12 size?: LogoSize; ··· 15 14 }>; 16 15 17 16 export function OpakeLogo({ size = "md", loading = false }: Props) { 18 - const { square, wrap, text } = SIZES[size]; 19 - const SOLID_BG = "oklch(0.58 0.095 75 / 0.7)"; 20 - const GHOST_BG = "oklch(0.58 0.095 75 / 0.2)"; 21 - const GHOST_BORDER = "1.5px solid oklch(0.58 0.095 75 / 0.45)"; 22 - const NO_BORDER = "0 solid transparent"; 23 - 24 - const square1Style = { 25 - width: square, 26 - height: square, 27 - "--space": `${wrap - square}px`, 28 - "--sq-dir": 1, 29 - "--sq-from": SOLID_BG, 30 - "--sq-to": GHOST_BG, 31 - "--sq-border-from": NO_BORDER, 32 - "--sq-border-to": GHOST_BORDER, 33 - background: SOLID_BG, 34 - border: NO_BORDER, 35 - } as React.CSSProperties; 36 - 37 - const square2Style = { 38 - width: square, 39 - height: square, 40 - "--space": `${wrap - square}px`, 41 - "--sq-dir": -1, 42 - "--sq-from": GHOST_BG, 43 - "--sq-to": SOLID_BG, 44 - "--sq-border-from": GHOST_BORDER, 45 - "--sq-border-to": NO_BORDER, 46 - background: GHOST_BG, 47 - border: GHOST_BORDER, 48 - } as React.CSSProperties; 49 - 50 - const square1Ref = useRef<HTMLDivElement>(null); 51 - const square2Ref = useRef<HTMLDivElement>(null); 52 - 53 - useEffect(() => { 54 - const els = [square1Ref.current, square2Ref.current].filter( 55 - (el): el is HTMLDivElement => el !== null, 56 - ); 57 - if (els.length === 0) return; 58 - 59 - if (loading) { 60 - els.forEach((el) => el.classList.add("animate-logo-square")); 61 - return; 62 - } 63 - 64 - // loading stopped — let the current cycle finish, then remove 65 - const handleIteration = (e: AnimationEvent) => { 66 - const el = e.currentTarget as HTMLElement; 67 - el.classList.remove("animate-logo-square"); 68 - el.removeEventListener("animationiteration", handleIteration as EventListener); 69 - }; 70 - 71 - els.forEach((el) => { 72 - if (!el.classList.contains("animate-logo-square")) return; 73 - el.addEventListener("animationiteration", handleIteration as EventListener); 74 - }); 75 - 76 - return () => { 77 - els.forEach((el) => 78 - el.removeEventListener("animationiteration", handleIteration as EventListener), 79 - ); 80 - }; 81 - }, [loading]); 82 - 83 17 return ( 84 18 <div className="flex items-center gap-2.5"> 85 - <div className="relative shrink-0" style={{ width: wrap, height: wrap }}> 86 - <div className="absolute top-0 left-0 rounded-sm" ref={square1Ref} style={square1Style} /> 87 - <div 88 - className="absolute right-0 bottom-0 rounded-sm" 89 - ref={square2Ref} 90 - style={square2Style} 91 - /> 92 - </div> 93 - <span className={`font-display text-base-content font-medium tracking-[0.05em] ${text}`}> 19 + <OpakeLogoSquares size={size} loading={loading} /> 20 + <span 21 + className={`font-display text-base-content font-medium tracking-[0.05em] ${TEXT_SIZES[size]}`} 22 + > 94 23 Opake 95 24 </span> 96 25 </div>
+91
web/src/components/OpakeLogoSquares.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + 3 + const SIZES = { 4 + sm: { square: 16, wrap: 22 }, 5 + md: { square: 22, wrap: 28 }, 6 + lg: { square: 30, wrap: 36 }, 7 + xl: { square: 40, wrap: 48 }, 8 + "2xl": { square: 54, wrap: 64 }, 9 + } as const; 10 + 11 + export type LogoSize = keyof typeof SIZES; 12 + 13 + type Props = Readonly<{ 14 + size?: LogoSize; 15 + loading?: boolean; 16 + }>; 17 + 18 + const SOLID_BG = "oklch(0.58 0.095 75 / 0.7)"; 19 + const GHOST_BG = "oklch(0.58 0.095 75 / 0.2)"; 20 + const GHOST_BORDER = "1.5px solid oklch(0.58 0.095 75 / 0.45)"; 21 + const NO_BORDER = "0 solid transparent"; 22 + 23 + export function OpakeLogoSquares({ size = "md", loading = false }: Props) { 24 + const { square, wrap } = SIZES[size]; 25 + 26 + const square1Style = { 27 + width: square, 28 + height: square, 29 + "--space": `${wrap - square}px`, 30 + "--sq-dir": 1, 31 + "--sq-from": SOLID_BG, 32 + "--sq-to": GHOST_BG, 33 + "--sq-border-from": NO_BORDER, 34 + "--sq-border-to": GHOST_BORDER, 35 + background: SOLID_BG, 36 + border: NO_BORDER, 37 + } as React.CSSProperties; 38 + 39 + const square2Style = { 40 + width: square, 41 + height: square, 42 + "--space": `${wrap - square}px`, 43 + "--sq-dir": -1, 44 + "--sq-from": GHOST_BG, 45 + "--sq-to": SOLID_BG, 46 + "--sq-border-from": GHOST_BORDER, 47 + "--sq-border-to": NO_BORDER, 48 + background: GHOST_BG, 49 + border: GHOST_BORDER, 50 + } as React.CSSProperties; 51 + 52 + const square1Ref = useRef<HTMLDivElement>(null); 53 + const square2Ref = useRef<HTMLDivElement>(null); 54 + 55 + useEffect(() => { 56 + const els = [square1Ref.current, square2Ref.current].filter( 57 + (el): el is HTMLDivElement => el !== null, 58 + ); 59 + if (els.length === 0) return; 60 + 61 + if (loading) { 62 + els.forEach((el) => el.classList.add("animate-logo-square")); 63 + return; 64 + } 65 + 66 + // loading stopped — let the current cycle finish, then remove 67 + const handleIteration = (e: AnimationEvent) => { 68 + const el = e.currentTarget as HTMLElement; 69 + el.classList.remove("animate-logo-square"); 70 + el.removeEventListener("animationiteration", handleIteration as EventListener); 71 + }; 72 + 73 + els.forEach((el) => { 74 + if (!el.classList.contains("animate-logo-square")) return; 75 + el.addEventListener("animationiteration", handleIteration as EventListener); 76 + }); 77 + 78 + return () => { 79 + els.forEach((el) => 80 + el.removeEventListener("animationiteration", handleIteration as EventListener), 81 + ); 82 + }; 83 + }, [loading]); 84 + 85 + return ( 86 + <div className="relative shrink-0" style={{ width: wrap, height: wrap }}> 87 + <div className="absolute top-0 left-0 rounded-sm" ref={square1Ref} style={square1Style} /> 88 + <div className="absolute right-0 bottom-0 rounded-sm" ref={square2Ref} style={square2Style} /> 89 + </div> 90 + ); 91 + }
+4 -2
web/src/components/cabinet/FileActionMenu.tsx
··· 1 1 import { DotsThreeVerticalIcon, DownloadSimpleIcon } from "@phosphor-icons/react"; 2 2 import { DropdownMenu } from "@/components/DropdownMenu"; 3 + import { useAppStore } from "@/stores/app"; 3 4 import type { FileItem } from "./types"; 4 5 5 6 interface FileActionMenuProps { 6 7 readonly item: FileItem; 7 - readonly downloading: boolean; 8 8 readonly onDownload?: () => void; 9 9 } 10 10 11 - export function FileActionMenu({ item, downloading, onDownload }: FileActionMenuProps) { 11 + export function FileActionMenu({ item, onDownload }: FileActionMenuProps) { 12 12 const isFolder = item.kind === "folder"; 13 + const downloading = useAppStore((s) => s.isLoading(`download:${item.uri}`)); 14 + 13 15 if (isFolder || !item.decrypted || item.name === "[Keyring encrypted]") return null; 14 16 15 17 if (downloading) {
+2 -8
web/src/components/cabinet/FileGridCard.tsx
··· 8 8 readonly item: FileItem; 9 9 readonly onClick: () => void; 10 10 readonly onDownload?: () => void; 11 - readonly downloading?: boolean; 12 11 } 13 12 14 - export function FileGridCard({ 15 - item, 16 - onClick, 17 - onDownload, 18 - downloading = false, 19 - }: FileGridCardProps) { 13 + export function FileGridCard({ item, onClick, onDownload }: FileGridCardProps) { 20 14 const { bg, text } = fileIconColors(item); 21 15 const isFolder = item.kind === "folder"; 22 16 ··· 49 43 {fileIconElement(item, 17)} 50 44 </div> 51 45 <div className="flex items-center gap-1"> 52 - <FileActionMenu item={item} downloading={downloading} onDownload={onDownload} /> 46 + <FileActionMenu item={item} onDownload={onDownload} /> 53 47 <LockIcon size={11} className="text-text-faint" /> 54 48 </div> 55 49 </div>
+2 -3
web/src/components/cabinet/FileListRow.tsx
··· 8 8 readonly item: FileItem; 9 9 readonly onClick: () => void; 10 10 readonly onDownload?: () => void; 11 - readonly downloading?: boolean; 12 11 } 13 12 14 - export function FileListRow({ item, onClick, onDownload, downloading = false }: FileListRowProps) { 13 + export function FileListRow({ item, onClick, onDownload }: FileListRowProps) { 15 14 const { bg, text } = fileIconColors(item); 16 15 const isFolder = item.kind === "folder"; 17 16 ··· 41 40 > 42 41 {/* Actions */} 43 42 <div className="w-6"> 44 - <FileActionMenu item={item} downloading={downloading} onDownload={onDownload} /> 43 + <FileActionMenu item={item} onDownload={onDownload} /> 45 44 </div> 46 45 47 46 {/* Icon */}
+1 -10
web/src/components/cabinet/PanelContent.tsx
··· 8 8 readonly viewMode: "list" | "grid"; 9 9 readonly onOpen: (item: FileItem) => void; 10 10 readonly onDownload: (uri: string) => void; 11 - readonly downloadingUris: ReadonlySet<string>; 12 11 } 13 12 14 - export function PanelContent({ 15 - items, 16 - viewMode, 17 - onOpen, 18 - onDownload, 19 - downloadingUris, 20 - }: PanelContentProps) { 13 + export function PanelContent({ items, viewMode, onOpen, onDownload }: PanelContentProps) { 21 14 if (items.length === 0) { 22 15 return ( 23 16 <div className="hero py-16"> ··· 41 34 item={item} 42 35 onClick={() => item.kind === "folder" && onOpen(item)} 43 36 onDownload={() => onDownload(item.uri)} 44 - downloading={downloadingUris.has(item.uri)} 45 37 /> 46 38 ))} 47 39 </div> ··· 53 45 item={item} 54 46 onClick={() => item.kind === "folder" && onOpen(item)} 55 47 onDownload={() => onDownload(item.uri)} 56 - downloading={downloadingUris.has(item.uri)} 57 48 /> 58 49 ))} 59 50 </div>
+4 -1
web/src/components/cabinet/Sidebar.tsx
··· 8 8 } from "@phosphor-icons/react"; 9 9 import { Link } from "@tanstack/react-router"; 10 10 import { OpakeLogo } from "../OpakeLogo"; 11 + import { useAppStore } from "@/stores/app"; 11 12 import { SidebarItem } from "./SidebarItem"; 12 13 13 14 const MAIN_NAV = [ ··· 28 29 ]; 29 30 30 31 export function Sidebar() { 32 + const anyLoading = useAppStore((s) => s.anythingLoading()); 33 + 31 34 return ( 32 35 <aside className="border-base-300/50 bg-base-200 flex w-53 shrink-0 flex-col border-r px-3 py-4"> 33 36 {/* Logo */} 34 37 <div className="mb-5 px-0.5"> 35 38 <Link to="/" className="inline-block"> 36 - <OpakeLogo /> 39 + <OpakeLogo loading={anyLoading} /> 37 40 </Link> 38 41 </div> 39 42
-2
web/src/routes/cabinet/files/$.tsx
··· 20 20 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 21 21 const viewMode = useDocumentsStore((s) => s.viewMode); 22 22 const downloadFile = useDocumentsStore((s) => s.downloadFile); 23 - const downloadingUris = useDocumentsStore((s) => s.downloadingUris); 24 23 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(currentDirectoryUri))); 25 24 26 25 useEffect(() => { ··· 43 42 viewMode={viewMode} 44 43 onOpen={handleOpen} 45 44 onDownload={(uri) => void downloadFile(uri)} 46 - downloadingUris={downloadingUris} 47 45 /> 48 46 ); 49 47 }
-2
web/src/routes/cabinet/files/index.tsx
··· 11 11 const ensureDirectoryDecrypted = useDocumentsStore((s) => s.ensureDirectoryDecrypted); 12 12 const viewMode = useDocumentsStore((s) => s.viewMode); 13 13 const downloadFile = useDocumentsStore((s) => s.downloadFile); 14 - const downloadingUris = useDocumentsStore((s) => s.downloadingUris); 15 14 const items = useDocumentsStore(useShallow((s) => s.itemsForDirectory(null))); 16 15 17 16 useEffect(() => { ··· 31 30 viewMode={viewMode} 32 31 onOpen={handleOpen} 33 32 onDownload={(uri) => void downloadFile(uri)} 34 - downloadingUris={downloadingUris} 35 33 /> 36 34 ); 37 35 }
+3 -2
web/src/routes/cabinet/files/route.tsx
··· 22 22 import { TagFilterBar } from "@/components/cabinet/TagFilterBar"; 23 23 import { useDocumentsStore } from "@/stores/documents"; 24 24 import { useAuthStore } from "@/stores/auth"; 25 + import { useAppStore } from "@/stores/app"; 25 26 import { directoryUri } from "@/lib/atUri"; 26 27 27 28 function FileBrowserLayout() { ··· 40 41 const did = session.status === "active" ? session.did : null; 41 42 const currentDirectoryUri = rkey && did ? directoryUri(did, rkey) : null; 42 43 43 - const loading = useDocumentsStore((s) => s.loading); 44 + const documentsLoading = useAppStore((s) => s.isLoading("documents-fetch")); 44 45 const viewMode = useDocumentsStore((s) => s.viewMode); 45 46 const setViewMode = useDocumentsStore((s) => s.setViewMode); 46 47 const ancestorsOf = useDocumentsStore((s) => s.ancestorsOf); ··· 188 189 onToggle={handleToggleTag} 189 190 onClear={() => setTagFilters([])} 190 191 /> 191 - {loading ? <PanelSkeleton /> : <Outlet />} 192 + {documentsLoading ? <PanelSkeleton /> : <Outlet />} 192 193 </PanelShell> 193 194 ); 194 195 }
+6
web/src/stores/app.ts
··· 37 37 anythingLoading: () => !!get().loadingItems.size, 38 38 })), 39 39 ); 40 + 41 + /** Register a named loading operation. Returns a cleanup function to call when done. */ 42 + export function loading(key: string): () => void { 43 + useAppStore.getState().addLoading(key); 44 + return () => useAppStore.getState().removeLoading(key); 45 + }
+1 -6
web/src/stores/auth.ts
··· 13 13 import { IndexedDbStorage } from "@/lib/indexeddbStorage"; 14 14 import { getCryptoWorker } from "@/lib/worker"; 15 15 import { authenticatedXrpc } from "@/lib/api"; 16 - import { useAppStore } from "@/stores/app"; 16 + import { loading } from "@/stores/app"; 17 17 import { 18 18 resolveHandleToPds, 19 19 discoverAuthorizationServer, ··· 73 73 // --------------------------------------------------------------------------- 74 74 75 75 const storage = new IndexedDbStorage(); 76 - 77 - function loading(key: string) { 78 - useAppStore.getState().addLoading(key); 79 - return () => useAppStore.getState().removeLoading(key); 80 - } 81 76 82 77 // --------------------------------------------------------------------------- 83 78 // Helpers
+10 -13
web/src/stores/documents/store.ts
··· 4 4 import { immer } from "zustand/middleware/immer"; 5 5 import { castDraft } from "immer"; 6 6 import { useAuthStore } from "@/stores/auth"; 7 + import { loading } from "@/stores/app"; 7 8 import { getCryptoWorker } from "@/lib/worker"; 8 9 import { base64ToUint8Array } from "@/lib/encoding"; 9 10 import type { FileItem } from "@/components/cabinet/types"; ··· 34 35 treeSnapshot: DirectoryTreeSnapshot | null; 35 36 documentRecords: Record<string, PdsRecord<DocumentRecord>>; 36 37 decryptedDirectories: Set<string>; 37 - loading: boolean; 38 38 error: string | null; 39 39 activeTagFilters: string[]; 40 - downloadingUris: Set<string>; 41 40 viewMode: "list" | "grid"; 42 41 43 42 readonly fetchAll: () => Promise<void>; ··· 59 58 treeSnapshot: null, 60 59 documentRecords: {}, 61 60 decryptedDirectories: new Set<string>(), 62 - loading: false, 63 61 error: null, 64 62 activeTagFilters: [], 65 - downloadingUris: new Set<string>(), 66 63 viewMode: "list", 67 64 68 65 fetchAll: async () => { ··· 70 67 if (authState.session.status !== "active") return; 71 68 72 69 const { did, pdsUrl } = authState.session; 70 + const done = loading("documents-fetch"); 73 71 74 72 set((draft) => { 75 - draft.loading = true; 76 73 draft.error = null; 77 74 draft.items = {}; 78 75 draft.treeSnapshot = null; ··· 131 128 draft.items = items; 132 129 draft.treeSnapshot = castDraft(snapshot); 133 130 draft.documentRecords = castDraft(docRecordsMap); 134 - draft.loading = false; 135 131 }); 132 + 133 + done(); 136 134 137 135 // Eagerly decrypt root directory's documents 138 136 await get().ensureDirectoryDecrypted(null); 139 137 } catch (error) { 140 138 console.error("[documents] fetchAll failed:", error); 139 + done(); 141 140 set((draft) => { 142 - draft.loading = false; 143 141 draft.error = error instanceof Error ? error.message : String(error); 144 142 }); 145 143 } ··· 173 171 174 172 // Filter to document entries (not in snapshot.directories = not a directory) 175 173 const documentUris = dirEntry.entries.filter((uri) => !(uri in treeSnapshot.directories)); 174 + const done = loading("decrypt-directory"); 176 175 177 176 // Decrypt sequentially to avoid overwhelming the worker 178 177 await documentUris.reduce(async (prev, uri) => { ··· 188 187 markDecryptionFailed(uri, set); 189 188 } 190 189 }, Promise.resolve()); 190 + 191 + done(); 191 192 }, 192 193 193 194 itemsForDirectory: (directoryUri: string | null): FileItem[] => { ··· 240 241 return; 241 242 } 242 243 243 - set((draft) => { 244 - draft.downloadingUris.add(documentUri); 245 - }); 244 + const done = loading(`download:${documentUri}`); 246 245 247 246 try { 248 247 const { did, pdsUrl } = authState.session; ··· 254 253 } catch (error) { 255 254 console.error("[documents] download failed:", documentUri, error); 256 255 } finally { 257 - set((draft) => { 258 - draft.downloadingUris.delete(documentUri); 259 - }); 256 + done(); 260 257 } 261 258 }, 262 259