An encrypted personal cloud built on the AT Protocol.

Harden cabinet frontend: discriminated unions, a11y, auth guard, design tokens

Structural improvements from adversarial code review:

- Rewrite Panel type as discriminated union (folder vs section) with panelKey() helper
- Add keyboard navigation and ARIA attributes to FileListRow and FileGridCard
- Add error boundary with retry to root route
- Add loading skeleton state to PanelStack
- Replace hardcoded API URLs with VITE_PDS_URL / VITE_APPVIEW_URL env vars
- Extract file-icon colors into theme tokens, add text scale tokens (text-ui, text-caption, text-label, text-micro)
- Add beforeLoad auth guard on cabinet route, build out login page
- Make starredIds a single source of truth (Set) instead of pre-filtered array
- Delete unused documents store, gitignore generated routeTree
- Add vite/client types to tsconfig

+383 -252
+2
.gitignore
··· 2 2 node_modules/ 3 3 web/dist/ 4 4 web/src/wasm/ 5 + web/src/routeTree.gen.ts 5 6 settings.local.json 6 7 .claude/settings.local.json 7 8 .mcp.json 9 + */.crosslink/issues.db 8 10 9 11 # === Crosslink managed (do not edit between markers) === 10 12 # .crosslink/ — machine-local state (never commit)
+19 -5
web/src/components/cabinet/FileGridCard.tsx
··· 10 10 11 11 export function FileGridCard({ item, onClick }: FileGridCardProps) { 12 12 const { bg, text } = fileIconColors(item); 13 + const isFolder = item.kind === "folder"; 13 14 14 15 return ( 15 16 <div 16 - onClick={onClick} 17 + onClick={isFolder ? onClick : undefined} 18 + onKeyDown={ 19 + isFolder 20 + ? (e) => { 21 + if (e.key === "Enter" || e.key === " ") { 22 + e.preventDefault(); 23 + onClick(); 24 + } 25 + } 26 + : undefined 27 + } 28 + role={isFolder ? "button" : "article"} 29 + tabIndex={isFolder ? 0 : undefined} 30 + aria-label={`${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}`} 17 31 className={`card border border-base-300/50 bg-base-100 p-4 shadow-panel-sm transition-all hover:border-base-300 hover:shadow-panel-md ${ 18 - item.kind === "folder" ? "cursor-pointer" : "" 32 + isFolder ? "cursor-pointer" : "" 19 33 }`} 20 34 > 21 35 <div className="mb-3 flex items-start justify-between"> ··· 28 42 </div> 29 43 30 44 {/* Encrypted preview area */} 31 - <div className="relative mb-3 flex h-[52px] items-center justify-center overflow-hidden rounded-lg border border-primary/10 bg-[repeating-linear-gradient(0deg,transparent,transparent_13px,oklch(0.580_0.095_75/0.04)_13px,oklch(0.580_0.095_75/0.04)_14px)]"> 45 + <div className="relative mb-3 flex h-[52px] items-center justify-center overflow-hidden rounded-lg border border-primary/10 bg-encrypted-pattern"> 32 46 <div className="absolute inset-0 bg-base-100/55 backdrop-blur-[3px]" /> 33 47 <Lock size={13} className="relative z-10 text-text-faint" /> 34 48 </div> 35 49 36 - <div className="mb-1.5 truncate text-[12px] text-base-content"> 50 + <div className="mb-1.5 truncate text-xs text-base-content"> 37 51 {item.name} 38 52 </div> 39 53 <div className="flex items-center justify-between"> 40 - <span className="text-[11px] text-text-faint">{item.modified}</span> 54 + <span className="text-caption text-text-faint">{item.modified}</span> 41 55 <StatusBadge status={item.status} /> 42 56 </div> 43 57 </div>
+21 -6
web/src/components/cabinet/FileListRow.tsx
··· 11 11 12 12 export function FileListRow({ item, onClick, onStar }: FileListRowProps) { 13 13 const { bg, text } = fileIconColors(item); 14 + const isFolder = item.kind === "folder"; 14 15 15 16 return ( 16 17 <div 17 - onClick={onClick} 18 - className={`flex items-center gap-3 rounded-[10px] px-3 py-[9px] transition-colors hover:bg-bg-hover ${ 19 - item.kind === "folder" ? "cursor-pointer" : "" 18 + onClick={isFolder ? onClick : undefined} 19 + onKeyDown={ 20 + isFolder 21 + ? (e) => { 22 + if (e.key === "Enter" || e.key === " ") { 23 + e.preventDefault(); 24 + onClick(); 25 + } 26 + } 27 + : undefined 28 + } 29 + role={isFolder ? "button" : "row"} 30 + tabIndex={isFolder ? 0 : undefined} 31 + aria-label={`${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}`} 32 + className={`flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors hover:bg-bg-hover ${ 33 + isFolder ? "cursor-pointer" : "" 20 34 }`} 21 35 > 22 36 {/* Icon */} ··· 28 42 29 43 {/* Name + meta */} 30 44 <div className="min-w-0 flex-1"> 31 - <div className="truncate text-[13px] text-base-content"> 45 + <div className="truncate text-ui text-base-content"> 32 46 {item.name} 33 47 </div> 34 - <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-text-faint"> 48 + <div className="mt-0.5 flex items-center gap-1.5 text-caption text-text-faint"> 35 49 <span>{item.modified}</span> 36 50 {item.size && ( 37 51 <> ··· 56 70 e.stopPropagation(); 57 71 onStar(); 58 72 }} 73 + aria-label={item.starred ? "Unstar" : "Star"} 59 74 className={`btn btn-ghost btn-xs p-0.5 ${ 60 75 item.starred ? "text-warning" : "text-text-faint" 61 76 }`} ··· 65 80 weight={item.starred ? "fill" : "regular"} 66 81 /> 67 82 </button> 68 - {item.kind === "folder" && ( 83 + {isFolder && ( 69 84 <CaretRight size={13} className="text-text-faint" /> 70 85 )} 71 86 </div>
+47 -34
web/src/components/cabinet/PanelContent.tsx
··· 18 18 import { 19 19 ROOT_ITEMS, 20 20 SHARED_ITEMS, 21 - STARRED_ITEMS, 22 21 DOCUMENTS_ITEMS, 23 22 } from "./mock-data"; 24 23 ··· 38 37 { label: "Notifications", desc: "Email & in-app alerts", icon: Bell }, 39 38 ]; 40 39 41 - function getItemsForPanel(panel: Panel): FileItem[] { 42 - switch (panel.type) { 43 - case "root": 44 - return ROOT_ITEMS; 45 - case "shared": 46 - return SHARED_ITEMS; 47 - case "starred": 48 - return STARRED_ITEMS; 49 - case "encrypted": 50 - return ROOT_ITEMS.filter((i) => i.status === "private"); 51 - case "folder": 52 - return panel.data?.id === "f-documents" 53 - ? DOCUMENTS_ITEMS 54 - : ROOT_ITEMS.slice(5); 55 - default: 56 - return []; 57 - } 40 + const ALL_ITEMS = [...ROOT_ITEMS, ...SHARED_ITEMS, ...DOCUMENTS_ITEMS]; 41 + 42 + function getItemsForPanel( 43 + panel: Panel, 44 + starredIds: ReadonlySet<string>, 45 + ): FileItem[] { 46 + const baseItems = (() => { 47 + switch (panel.type) { 48 + case "root": 49 + return ROOT_ITEMS; 50 + case "shared": 51 + return SHARED_ITEMS; 52 + case "starred": 53 + return ALL_ITEMS.filter((i) => starredIds.has(i.id)); 54 + case "encrypted": 55 + return ROOT_ITEMS.filter((i) => i.status === "private"); 56 + case "folder": 57 + return panel.folderId === "f-documents" 58 + ? DOCUMENTS_ITEMS 59 + : ROOT_ITEMS.slice(5); 60 + default: 61 + return []; 62 + } 63 + })(); 64 + 65 + return baseItems.map((item) => ({ 66 + ...item, 67 + starred: starredIds.has(item.id), 68 + })); 58 69 } 59 70 60 71 interface PanelContentProps { 61 72 panel: Panel; 62 73 viewMode: "list" | "grid"; 74 + starredIds: ReadonlySet<string>; 63 75 onOpen: (item: FileItem) => void; 64 76 onStar: (id: string) => void; 65 77 } ··· 67 79 export function PanelContent({ 68 80 panel, 69 81 viewMode, 82 + starredIds, 70 83 onOpen, 71 84 onStar, 72 85 }: PanelContentProps) { ··· 75 88 return ( 76 89 <div className="p-5"> 77 90 <div className="mb-5"> 78 - <div className="mb-1 text-[13px] font-medium text-base-content"> 91 + <div className="mb-1 text-ui font-medium text-base-content"> 79 92 Documentation 80 93 </div> 81 - <div className="text-[12px] text-text-muted"> 94 + <div className="text-xs text-text-muted"> 82 95 Everything you need to get the most out of Opake. 83 96 </div> 84 97 </div> ··· 86 99 {DOCS_SECTIONS.map((s) => ( 87 100 <div 88 101 key={s.id} 89 - className="flex cursor-pointer items-start gap-3 rounded-[10px] border border-base-300/50 bg-base-100 p-3.5" 102 + className="card card-bordered cursor-pointer border-base-300/50 bg-base-100 p-3.5" 90 103 > 91 104 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent"> 92 105 <s.icon size={14} className="text-primary" /> 93 106 </div> 94 107 <div className="flex-1"> 95 - <div className="mb-0.5 text-[13px] font-medium text-base-content"> 108 + <div className="mb-0.5 text-ui font-medium text-base-content"> 96 109 {s.title} 97 110 </div> 98 - <div className="text-[11px] leading-relaxed text-text-muted"> 111 + <div className="text-caption leading-relaxed text-text-muted"> 99 112 {s.desc} 100 113 </div> 101 114 </div> ··· 115 128 return ( 116 129 <div className="p-5"> 117 130 <div className="mb-5"> 118 - <div className="mb-1 text-[13px] font-medium text-base-content"> 131 + <div className="mb-1 text-ui font-medium text-base-content"> 119 132 Settings 120 133 </div> 121 - <div className="text-[12px] text-text-muted"> 134 + <div className="text-xs text-text-muted"> 122 135 Manage your account, keys, and preferences. 123 136 </div> 124 137 </div> ··· 127 140 {SETTINGS_SECTIONS.map(({ label, desc, icon: Icon }) => ( 128 141 <div 129 142 key={label} 130 - className="flex cursor-pointer items-center gap-3 rounded-[10px] border border-base-300/50 bg-base-100 p-3.5" 143 + className="card card-bordered cursor-pointer border-base-300/50 bg-base-100 p-3.5" 131 144 > 132 145 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-stone"> 133 146 <Icon size={14} className="text-text-muted" /> 134 147 </div> 135 148 <div className="flex-1"> 136 - <div className="text-[13px] font-medium text-base-content"> 149 + <div className="text-ui font-medium text-base-content"> 137 150 {label} 138 151 </div> 139 - <div className="text-[11px] text-text-muted">{desc}</div> 152 + <div className="text-caption text-text-muted">{desc}</div> 140 153 </div> 141 154 <CaretRight size={13} className="text-text-faint" /> 142 155 </div> ··· 151 164 return ( 152 165 <div className="hero py-16"> 153 166 <div className="hero-content flex-col text-center"> 154 - <div className="flex size-[52px] items-center justify-center rounded-[14px] bg-bg-stone"> 167 + <div className="flex size-13 items-center justify-center rounded-[14px] bg-bg-stone"> 155 168 <Trash size={22} className="text-text-faint" /> 156 169 </div> 157 - <div className="text-[13px] text-text-muted">Trash is empty</div> 158 - <div className="max-w-[240px] text-[12px] leading-relaxed text-text-faint"> 170 + <div className="text-ui text-text-muted">Trash is empty</div> 171 + <div className="max-w-60 text-xs leading-relaxed text-text-faint"> 159 172 Deleted files appear here for 30 days before permanent removal. 160 173 </div> 161 174 </div> ··· 164 177 } 165 178 166 179 // File browser (list / grid) 167 - const items = getItemsForPanel(panel); 180 + const items = getItemsForPanel(panel, starredIds); 168 181 169 182 if (items.length === 0) { 170 183 return ( 171 184 <div className="hero py-16"> 172 185 <div className="hero-content flex-col text-center"> 173 - <div className="flex size-[52px] items-center justify-center rounded-[14px] bg-accent"> 186 + <div className="flex size-13 items-center justify-center rounded-[14px] bg-accent"> 174 187 <Folder size={22} className="text-text-faint" /> 175 188 </div> 176 - <div className="text-[13px] text-text-muted">Nothing here yet</div> 189 + <div className="text-ui text-text-muted">Nothing here yet</div> 177 190 </div> 178 191 </div> 179 192 );
+9
web/src/components/cabinet/PanelSkeleton.tsx
··· 1 + export function PanelSkeleton() { 2 + return ( 3 + <div className="flex flex-col gap-2 p-3"> 4 + {Array.from({ length: 5 }).map((_, i) => ( 5 + <div key={i} className="skeleton h-12 w-full rounded-xl" /> 6 + ))} 7 + </div> 8 + ); 9 + }
+67 -67
web/src/components/cabinet/PanelStack.tsx
··· 1 - import { useState, useRef, useEffect } from "react"; 2 1 import { 3 - CaretRight, 4 2 ListBullets, 5 3 SquaresFour, 6 4 Plus, ··· 15 13 Lock, 16 14 } from "@phosphor-icons/react"; 17 15 import { PanelContent } from "./PanelContent"; 16 + import { PanelSkeleton } from "./PanelSkeleton"; 18 17 import { fileIconElement, fileIconColors } from "./file-icons"; 19 - import { ROOT_ITEMS, SHARED_ITEMS, STARRED_ITEMS } from "./mock-data"; 18 + import { ROOT_ITEMS, SHARED_ITEMS } from "./mock-data"; 20 19 import type { FileItem, Panel } from "./types"; 20 + import { panelKey } from "./types"; 21 21 22 22 const FILE_BROWSER_TYPES = new Set([ 23 23 "root", ··· 30 30 interface PanelStackProps { 31 31 panels: Panel[]; 32 32 viewMode: "list" | "grid"; 33 + starredIds: ReadonlySet<string>; 34 + loading: boolean; 33 35 onViewModeChange: (mode: "list" | "grid") => void; 34 36 onOpenItem: (item: FileItem) => void; 35 37 onGoToPanel: (index: number) => void; ··· 40 42 export function PanelStack({ 41 43 panels, 42 44 viewMode, 45 + starredIds, 46 + loading, 43 47 onViewModeChange, 44 48 onOpenItem, 45 49 onGoToPanel, 46 50 onClosePanel, 47 51 onStar, 48 52 }: PanelStackProps) { 49 - const [showNewMenu, setShowNewMenu] = useState(false); 50 - const newMenuRef = useRef<HTMLDivElement>(null); 51 - 52 - useEffect(() => { 53 - function handleClickOutside(e: MouseEvent) { 54 - if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) { 55 - setShowNewMenu(false); 56 - } 57 - } 58 - document.addEventListener("mousedown", handleClickOutside); 59 - return () => document.removeEventListener("mousedown", handleClickOutside); 60 - }, []); 61 - 62 53 const currentPanel = panels[panels.length - 1]; 63 54 const depth = panels.length; 64 55 const isFileBrowser = FILE_BROWSER_TYPES.has(currentPanel.type); ··· 70 61 case "shared": 71 62 return `${SHARED_ITEMS.length} shared items · Encrypted`; 72 63 case "starred": 73 - return `${STARRED_ITEMS.length} starred items`; 64 + return `${starredIds.size} starred items`; 74 65 case "encrypted": 75 66 return `${ROOT_ITEMS.filter((i) => i.status === "private").length} private items`; 76 67 case "folder": 77 - return `${currentPanel.data?.items ?? "–"} items · Encrypted`; 68 + return `${currentPanel.itemCount ?? "–"} items · Encrypted`; 78 69 case "docs": 79 70 return "Documentation · Opake"; 80 71 case "settings": ··· 99 90 {/* Panel header */} 100 91 <div className="flex shrink-0 items-center gap-2.5 border-b border-base-300/50 bg-base-100/70 px-4 py-[11px]"> 101 92 {/* Breadcrumb */} 102 - <div className="breadcrumbs min-w-0 flex-1 overflow-hidden text-[13px]"> 93 + <div className="breadcrumbs min-w-0 flex-1 overflow-hidden text-ui"> 103 94 <ul> 104 95 {panels.map((panel, i) => ( 105 - <li key={panel.id}> 96 + <li key={panelKey(panel)}> 106 97 <button 107 98 onClick={() => onGoToPanel(i)} 108 99 className={ ··· 147 138 )} 148 139 149 140 {/* New button */} 150 - <div className="relative" ref={newMenuRef}> 151 - <button 152 - onClick={() => setShowNewMenu((v) => !v)} 153 - className="btn btn-neutral btn-sm gap-1.5 rounded-lg text-[12px]" 154 - > 141 + <details className="dropdown dropdown-end"> 142 + <summary className="btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs"> 155 143 <Plus size={13} /> 156 144 New 157 - </button> 158 - {showNewMenu && ( 159 - <div className="menu dropdown-content absolute right-0 top-[calc(100%+6px)] z-50 w-[168px] rounded-xl border border-base-300/50 bg-base-100 p-1 shadow-panel-lg"> 160 - {[ 161 - { icon: UploadSimple, label: "Upload file" }, 162 - { icon: Folder, label: "New folder" }, 163 - { icon: FileText, label: "New document" }, 164 - { icon: BookOpen, label: "New note" }, 165 - ].map(({ icon: Icon, label }) => ( 145 + </summary> 146 + <ul className="menu dropdown-content z-50 w-[168px] rounded-xl border border-base-300/50 bg-base-100 p-1 shadow-panel-lg"> 147 + {[ 148 + { icon: UploadSimple, label: "Upload file" }, 149 + { icon: Folder, label: "New folder" }, 150 + { icon: FileText, label: "New document" }, 151 + { icon: BookOpen, label: "New note" }, 152 + ].map(({ icon: Icon, label }) => ( 153 + <li key={label}> 166 154 <button 167 - key={label} 168 - onClick={() => setShowNewMenu(false)} 169 - className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[12px] text-secondary hover:bg-bg-hover" 155 + onClick={(e) => { 156 + ( 157 + e.currentTarget.closest( 158 + "details", 159 + ) as HTMLDetailsElement 160 + )?.removeAttribute("open"); 161 + }} 162 + className="gap-2.5 text-xs text-secondary" 170 163 > 171 164 <Icon size={13} className="text-text-muted" /> 172 165 {label} 173 166 </button> 174 - ))} 175 - </div> 176 - )} 177 - </div> 167 + </li> 168 + ))} 169 + </ul> 170 + </details> 178 171 179 172 {/* Close panel */} 180 173 {depth > 1 && ( 181 174 <button 182 175 onClick={onClosePanel} 183 - className="btn btn-ghost btn-sm btn-square rounded-[7px]" 176 + className="btn btn-ghost btn-sm btn-square rounded-md" 184 177 > 185 178 <X size={14} className="text-text-muted" /> 186 179 </button> ··· 195 188 <div className="px-4 pt-4"> 196 189 <div className="mb-3 flex items-center gap-[7px]"> 197 190 <Clock size={12} className="text-text-faint" /> 198 - <span className="text-[10px] uppercase tracking-[0.1em] text-text-faint"> 191 + <span className="text-label uppercase tracking-[0.1em] text-text-faint"> 199 192 Recent 200 193 </span> 201 194 </div> ··· 205 198 return ( 206 199 <div 207 200 key={`r-${item.id}`} 208 - className="w-[130px] shrink-0 cursor-pointer rounded-[10px] border border-base-300/50 bg-base-100 p-3" 201 + className="card card-bordered w-[130px] shrink-0 cursor-pointer border-base-300/50 bg-base-100 p-3" 209 202 > 210 203 <div 211 - className={`mb-2 flex size-[26px] items-center justify-center rounded-[7px] ${bg} ${text}`} 204 + className={`mb-2 flex size-[26px] items-center justify-center rounded-md ${bg} ${text}`} 212 205 > 213 206 {fileIconElement(item, 13)} 214 207 </div> 215 - <div className="truncate text-[11px] text-base-content"> 208 + <div className="truncate text-caption text-base-content"> 216 209 {item.name} 217 210 </div> 218 - <div className="mt-0.5 text-[10px] text-text-faint"> 211 + <div className="mt-0.5 text-label text-text-faint"> 219 212 {item.modified} 220 213 </div> 221 214 </div> ··· 223 216 })} 224 217 </div> 225 218 {/* Ornamental divider */} 226 - <div className="mb-1 flex items-center gap-2.5"> 227 - <div className="h-px flex-1 bg-base-300/50" /> 228 - <span className="text-[9px] uppercase tracking-[0.12em] text-text-faint"> 229 - All files 230 - </span> 231 - <div className="h-px flex-1 bg-base-300/50" /> 219 + <div className="divider mb-1 text-micro uppercase tracking-[0.12em] text-text-faint"> 220 + All files 232 221 </div> 233 222 </div> 234 223 )} 235 224 236 225 {/* Section notices */} 237 226 {currentPanel.type === "shared" && ( 238 - <div className="mx-4 mt-4 flex items-start gap-2.5 rounded-[10px] border border-success/30 bg-bg-sage p-3"> 227 + <div 228 + role="alert" 229 + className="alert mx-4 mt-4 gap-2.5 rounded-xl border-success/30 bg-bg-sage p-3" 230 + > 239 231 <Users 240 232 size={13} 241 233 className="mt-0.5 shrink-0 text-success" 242 234 /> 243 235 <div> 244 - <div className="mb-0.5 text-[12px] font-medium text-success"> 236 + <div className="mb-0.5 text-xs font-medium text-success"> 245 237 Shared via decentralised identity 246 238 </div> 247 - <div className="text-[11px] leading-relaxed text-success/80"> 239 + <div className="text-caption leading-relaxed text-success/80"> 248 240 Files shared via DID. Encrypted in transit and at rest — only 249 241 invited parties can decrypt. 250 242 </div> ··· 252 244 </div> 253 245 )} 254 246 {currentPanel.type === "encrypted" && ( 255 - <div className="mx-4 mt-4 flex items-start gap-2.5 rounded-[10px] border border-border-accent bg-accent p-3"> 247 + <div 248 + role="alert" 249 + className="alert mx-4 mt-4 gap-2.5 rounded-xl border-border-accent bg-accent p-3" 250 + > 256 251 <Lock 257 252 size={13} 258 253 className="mt-0.5 shrink-0 text-primary" 259 254 /> 260 255 <div> 261 - <div className="mb-0.5 text-[12px] font-medium text-accent-content"> 256 + <div className="mb-0.5 text-xs font-medium text-accent-content"> 262 257 Private encrypted files 263 258 </div> 264 - <div className="text-[11px] leading-relaxed text-primary"> 259 + <div className="text-caption leading-relaxed text-primary"> 265 260 Only you can decrypt these files. Not shared with anyone. 266 261 </div> 267 262 </div> 268 263 </div> 269 264 )} 270 265 271 - <PanelContent 272 - panel={currentPanel} 273 - viewMode={viewMode} 274 - onOpen={onOpenItem} 275 - onStar={onStar} 276 - /> 266 + {loading ? ( 267 + <PanelSkeleton /> 268 + ) : ( 269 + <PanelContent 270 + panel={currentPanel} 271 + viewMode={viewMode} 272 + starredIds={starredIds} 273 + onOpen={onOpenItem} 274 + onStar={onStar} 275 + /> 276 + )} 277 277 </div> 278 278 279 279 {/* Panel footer */} 280 280 <div className="flex shrink-0 items-center gap-2 border-t border-base-300/50 bg-base-100/60 px-4 py-[9px]"> 281 281 <ShieldCheck size={11} className="text-primary" /> 282 - <span className="text-[11px] text-text-faint">{footerText}</span> 282 + <span className="text-caption text-text-faint">{footerText}</span> 283 283 <div className="flex-1" /> 284 284 {depth > 1 && ( 285 - <span className="font-display text-[13px] italic text-text-faint"> 285 + <span className="font-display text-ui italic text-text-faint"> 286 286 {depth} panels open 287 287 </span> 288 288 )}
+7 -7
web/src/components/cabinet/Sidebar.tsx
··· 10 10 import { Link } from "@tanstack/react-router"; 11 11 import { OpakeLogo } from "../OpakeLogo"; 12 12 import { SidebarItem } from "./SidebarItem"; 13 - import type { PanelType } from "./types"; 13 + import type { PanelType, SectionType } from "./types"; 14 14 15 15 const MAIN_NAV = [ 16 16 { type: "root" as const, icon: Folder, label: "The Cabinet" }, ··· 33 33 interface SidebarProps { 34 34 activePanelType: PanelType; 35 35 panelDepth: number; 36 - onOpenSection: (type: PanelType, title: string) => void; 36 + onOpenSection: (type: SectionType, title: string) => void; 37 37 } 38 38 39 39 export function Sidebar({ ··· 52 52 53 53 {/* Storage */} 54 54 <div className="mb-5 px-1"> 55 - <div className="mb-1.5 flex justify-between text-[11px] text-text-faint"> 55 + <div className="mb-1.5 flex justify-between text-caption text-text-faint"> 56 56 <span>Storage</span> 57 57 <span>3.1 / 10 GB</span> 58 58 </div> ··· 79 79 ))} 80 80 81 81 {/* Workspaces */} 82 - <div className="mt-3.5 mb-1.5 ml-1 text-[10px] uppercase tracking-[0.1em] text-text-faint"> 82 + <div className="mt-3.5 mb-1.5 ml-1 text-label uppercase tracking-[0.1em] text-text-faint"> 83 83 Workspaces 84 84 </div> 85 85 {WORKSPACES.map((ws) => ( 86 86 <button 87 87 key={ws.id} 88 - className="flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-[7px] text-left text-[13px] text-text-muted hover:bg-bg-hover" 88 + className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-left text-ui text-text-muted hover:bg-bg-hover" 89 89 > 90 - <div className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-accent text-[9px] font-semibold text-primary"> 90 + <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-accent text-micro font-semibold text-primary"> 91 91 {ws.name[0]} 92 92 </div> 93 93 <span className="flex-1">{ws.name}</span> 94 - <span className="text-[10px] text-text-faint">{ws.count}</span> 94 + <span className="text-label text-text-faint">{ws.count}</span> 95 95 </button> 96 96 ))} 97 97 </nav>
+2 -2
web/src/components/cabinet/SidebarItem.tsx
··· 18 18 return ( 19 19 <button 20 20 onClick={onClick} 21 - className={`flex w-full items-center gap-2.5 rounded-[9px] px-2.5 py-[7px] text-left text-[13px] transition-colors ${ 21 + className={`flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-left text-ui transition-colors ${ 22 22 active 23 23 ? "bg-accent text-primary" 24 24 : "text-text-muted hover:bg-bg-hover" ··· 31 31 <span className="flex-1">{label}</span> 32 32 {badge !== undefined && ( 33 33 <span 34 - className={`rounded-[5px] px-1.5 py-px text-[10px] ${ 34 + className={`badge badge-xs rounded-md ${ 35 35 active 36 36 ? "bg-primary/20 text-primary" 37 37 : "bg-primary/10 text-text-muted"
+1 -1
web/src/components/cabinet/StatusBadge.tsx
··· 28 28 29 29 return ( 30 30 <span 31 - className={`badge badge-sm gap-1 border text-[10px] tracking-wide ${variant.className}`} 31 + className={`badge badge-sm gap-1 border text-label tracking-wide ${variant.className}`} 32 32 > 33 33 <Icon size={8} weight="bold" /> 34 34 {variant.label}
+52 -64
web/src/components/cabinet/TopBar.tsx
··· 1 - import { useState, useRef, useEffect } from "react"; 2 1 import { 3 2 MagnifyingGlass, 4 3 X, ··· 8 7 Lock, 9 8 Gear, 10 9 SignOut, 11 - ShareNetwork, 12 10 } from "@phosphor-icons/react"; 13 11 import { Link } from "@tanstack/react-router"; 14 12 ··· 16 14 searchQuery: string; 17 15 onSearchChange: (query: string) => void; 18 16 onOpenSettings: () => void; 17 + } 18 + 19 + function closeDropdown(e: React.MouseEvent) { 20 + (e.currentTarget.closest("details") as HTMLDetailsElement)?.removeAttribute( 21 + "open", 22 + ); 19 23 } 20 24 21 25 export function TopBar({ ··· 23 27 onSearchChange, 24 28 onOpenSettings, 25 29 }: TopBarProps) { 26 - const [showUserMenu, setShowUserMenu] = useState(false); 27 - const userMenuRef = useRef<HTMLDivElement>(null); 28 - 29 - useEffect(() => { 30 - function handleClickOutside(e: MouseEvent) { 31 - if ( 32 - userMenuRef.current && 33 - !userMenuRef.current.contains(e.target as Node) 34 - ) { 35 - setShowUserMenu(false); 36 - } 37 - } 38 - document.addEventListener("mousedown", handleClickOutside); 39 - return () => document.removeEventListener("mousedown", handleClickOutside); 40 - }, []); 41 - 42 30 return ( 43 31 <header className="flex shrink-0 items-center gap-3 border-b border-base-300/50 bg-base-300/90 px-5 py-2.5 backdrop-blur-[10px]"> 44 32 {/* Search */} 45 - <label className="input input-bordered flex max-w-[360px] flex-1 items-center gap-2 rounded-[9px] border-base-300/50 bg-base-100/80 py-[7px] text-[13px]"> 33 + <label className="input input-bordered flex max-w-[360px] flex-1 items-center gap-2 rounded-lg border-base-300/50 bg-base-100/80 py-[7px] text-ui"> 46 34 <MagnifyingGlass size={13} className="text-text-faint" /> 47 35 <input 48 36 type="text" ··· 64 52 <div className="flex-1" /> 65 53 66 54 {/* E2E badge */} 67 - <div className="flex items-center gap-1.5 rounded-lg border border-border-accent bg-accent px-3 py-[5px] text-[11px] text-primary"> 55 + <div className="badge badge-outline gap-1.5 border-border-accent bg-accent py-3 text-caption text-primary"> 68 56 <ShieldCheck size={12} weight="bold" /> 69 57 End-to-end encrypted 70 58 </div> 71 59 72 60 {/* Notifications */} 73 - <button className="btn btn-ghost btn-sm btn-square relative rounded-[9px]"> 74 - <Bell size={15} className="text-text-muted" /> 75 - <span className="indicator-item badge badge-primary badge-xs absolute top-1.5 right-1.5 size-1.5 p-0" /> 76 - </button> 61 + <div className="indicator"> 62 + <span className="indicator-item badge badge-primary badge-xs size-1.5 p-0" /> 63 + <button className="btn btn-ghost btn-sm btn-square rounded-lg"> 64 + <Bell size={15} className="text-text-muted" /> 65 + </button> 66 + </div> 77 67 78 68 {/* User menu */} 79 - <div className="relative" ref={userMenuRef}> 80 - <button 81 - onClick={() => setShowUserMenu((v) => !v)} 82 - className="btn btn-ghost btn-sm gap-2 rounded-[9px] pl-1" 83 - > 84 - <div className="flex size-7 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-primary"> 69 + <details className="dropdown dropdown-end"> 70 + <summary className="btn btn-ghost btn-sm gap-2 rounded-lg pl-1"> 71 + <div className="flex size-7 items-center justify-center rounded-full bg-accent text-caption font-semibold text-primary"> 85 72 A 86 73 </div> 87 - <span className="text-[12px] font-normal text-secondary"> 74 + <span className="text-xs font-normal text-secondary"> 88 75 alice.bsky.social 89 76 </span> 90 - </button> 91 - 92 - {showUserMenu && ( 93 - <div className="menu dropdown-content absolute right-0 top-[calc(100%+8px)] z-50 w-[210px] rounded-[14px] border border-base-300/50 bg-base-100 p-1 shadow-panel-lg"> 94 - <div className="border-b border-base-300/50 px-3.5 py-2.5"> 95 - <div className="text-[13px] font-medium text-base-content"> 96 - alice.bsky.social 97 - </div> 98 - <div className="mt-0.5 text-[11px] text-text-faint"> 99 - did:plc:7f2ab3c4…8e91 100 - </div> 77 + </summary> 78 + <div className="dropdown-content z-50 w-[210px] rounded-xl border border-base-300/50 bg-base-100 shadow-panel-lg"> 79 + <div className="border-b border-base-300/50 px-3.5 py-2.5"> 80 + <div className="text-ui font-medium text-base-content"> 81 + alice.bsky.social 82 + </div> 83 + <div className="mt-0.5 text-caption text-text-faint"> 84 + did:plc:7f2ab3c4…8e91 101 85 </div> 86 + </div> 87 + <ul className="menu p-1"> 102 88 {[ 103 89 { icon: User, label: "Profile & DID" }, 104 90 { icon: Lock, label: "Encryption Keys" }, 105 91 { icon: Gear, label: "Settings" }, 106 92 ].map(({ icon: Icon, label }) => ( 107 - <button 108 - key={label} 109 - onClick={() => { 110 - onOpenSettings(); 111 - setShowUserMenu(false); 112 - }} 113 - className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[12px] text-secondary hover:bg-bg-hover" 114 - > 115 - <Icon size={13} /> 116 - {label} 117 - </button> 93 + <li key={label}> 94 + <button 95 + onClick={(e) => { 96 + onOpenSettings(); 97 + closeDropdown(e); 98 + }} 99 + className="gap-2.5 text-xs text-secondary" 100 + > 101 + <Icon size={13} /> 102 + {label} 103 + </button> 104 + </li> 118 105 ))} 119 - <div className="divider my-0.5" /> 120 - <Link 121 - to="/" 122 - className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-[12px] text-error hover:bg-bg-hover" 123 - > 124 - <SignOut size={13} /> 125 - Sign out 126 - </Link> 127 - </div> 128 - )} 129 - </div> 106 + </ul> 107 + <div className="divider my-0.5" /> 108 + <ul className="menu p-1 pt-0"> 109 + <li> 110 + <Link to="/" className="gap-2.5 text-xs text-error"> 111 + <SignOut size={13} /> 112 + Sign out 113 + </Link> 114 + </li> 115 + </ul> 116 + </div> 117 + </details> 130 118 </header> 131 119 ); 132 120 }
+5 -5
web/src/components/cabinet/file-icons.tsx
··· 15 15 const FOLDER_STYLE: IconStyle = { bg: "bg-accent", text: "text-primary" }; 16 16 17 17 const FILE_TYPE_STYLES: Record<string, IconStyle> = { 18 - document: { bg: "bg-[#EEF0F8]", text: "text-[#6676A8]" }, 19 - spreadsheet: { bg: "bg-[#EEF4EE]", text: "text-[#5C8A5C]" }, 20 - pdf: { bg: "bg-[#F5EEEC]", text: "text-[#A05040]" }, 21 - note: { bg: "bg-accent", text: "text-[#8A6A30]" }, 22 - code: { bg: "bg-[#F0EEF5]", text: "text-[#7A6A98]" }, 18 + document: { bg: "bg-file-doc-bg", text: "text-file-doc" }, 19 + spreadsheet: { bg: "bg-file-sheet-bg", text: "text-file-sheet" }, 20 + pdf: { bg: "bg-file-pdf-bg", text: "text-file-pdf" }, 21 + note: { bg: "bg-accent", text: "text-file-note" }, 22 + code: { bg: "bg-file-code-bg", text: "text-file-code" }, 23 23 archive: { bg: "bg-bg-stone", text: "text-text-muted" }, 24 24 }; 25 25
-2
web/src/components/cabinet/mock-data.ts
··· 27 27 { id: "sh-3", name: "Brand Assets", kind: "folder", encrypted: true, status: "shared", sharedWith: ["design.did"], items: 34, modified: "2 days ago", starred: false }, 28 28 { id: "sh-4", name: "Meeting Notes Q1.md", kind: "file", fileType: "note", encrypted: true, status: "shared", sharedWith: ["alice.did"], size: "22 KB", modified: "1 week ago", starred: false }, 29 29 ]; 30 - 31 - export const STARRED_ITEMS = ROOT_ITEMS.filter((i) => i.starred);
+9 -7
web/src/components/cabinet/types.ts
··· 23 23 starred: boolean; 24 24 } 25 25 26 - export type PanelType = 26 + export type SectionType = 27 27 | "root" 28 - | "folder" 29 28 | "shared" 30 29 | "starred" 31 30 | "encrypted" ··· 33 32 | "trash" 34 33 | "settings"; 35 34 36 - export interface Panel { 37 - id: string; 38 - type: PanelType; 39 - title: string; 40 - data?: FileItem; 35 + export type PanelType = SectionType | "folder"; 36 + 37 + export type Panel = 38 + | { type: "folder"; folderId: string; title: string; itemCount?: number } 39 + | { type: SectionType; title: string }; 40 + 41 + export function panelKey(panel: Panel): string { 42 + return panel.type === "folder" ? panel.folderId : panel.type; 41 43 }
+31
web/src/index.css
··· 54 54 --font-display: "Cormorant Garamond", serif; 55 55 --font-sans: "Inter", sans-serif; 56 56 57 + /* Text scale */ 58 + --text-ui: 0.8125rem; /* 13px — primary UI text */ 59 + --text-caption: 0.6875rem; /* 11px — meta/caption */ 60 + --text-label: 0.625rem; /* 10px — small labels */ 61 + --text-micro: 0.5625rem; /* 9px — tiny text */ 62 + 63 + /* Custom colours */ 57 64 --color-border-accent: oklch(0.790 0.060 80); /* #D4BC96 */ 58 65 --color-text-faint: oklch(0.760 0.035 75); /* #C4B09A */ 59 66 --color-text-muted: oklch(0.620 0.040 70); /* #9A8768 */ ··· 63 70 --color-bg-sage: oklch(0.940 0.020 140); /* #EEF2E8 */ 64 71 --color-bg-stone: oklch(0.950 0.010 80); /* #F4F1EC */ 65 72 73 + /* File-type icon colours */ 74 + --color-file-doc: #6676A8; 75 + --color-file-doc-bg: #EEF0F8; 76 + --color-file-sheet: #5C8A5C; 77 + --color-file-sheet-bg: #EEF4EE; 78 + --color-file-pdf: #A05040; 79 + --color-file-pdf-bg: #F5EEEC; 80 + --color-file-note: #8A6A30; 81 + --color-file-code: #7A6A98; 82 + --color-file-code-bg: #F0EEF5; 83 + 84 + /* Elevation */ 66 85 --shadow-panel-sm: 0 1px 8px oklch(0.35 0.05 60 / 0.07); 67 86 --shadow-panel-md: 0 2px 16px oklch(0.35 0.05 60 / 0.09); 68 87 --shadow-panel-lg: 0 6px 32px oklch(0.35 0.05 60 / 0.12); 88 + } 89 + 90 + /* ─── Utilities ──────────────────────────────────────────────────────────── */ 91 + 92 + @utility bg-encrypted-pattern { 93 + background-image: repeating-linear-gradient( 94 + 0deg, 95 + transparent, 96 + transparent 13px, 97 + oklch(0.580 0.095 75 / 0.04) 13px, 98 + oklch(0.580 0.095 75 / 0.04) 14px 99 + ); 69 100 } 70 101 71 102 /* ─── Base styles ─────────────────────────────────────────────────────────── */
+2 -2
web/src/lib/api.ts
··· 4 4 } 5 5 6 6 const defaultConfig: ApiConfig = { 7 - pdsUrl: "https://pds.sans-self.org", 8 - appviewUrl: "https://appview.opake.app", 7 + pdsUrl: import.meta.env.VITE_PDS_URL ?? "https://pds.sans-self.org", 8 + appviewUrl: import.meta.env.VITE_APPVIEW_URL ?? "https://appview.opake.app", 9 9 }; 10 10 11 11 interface XrpcParams {
+23 -1
web/src/routes/__root.tsx
··· 1 - import { createRootRoute, Outlet } from "@tanstack/react-router"; 1 + import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router"; 2 2 3 3 function RootLayout() { 4 4 return <Outlet />; 5 5 } 6 6 7 + function RootError({ error }: { error: Error }) { 8 + const router = useRouter(); 9 + 10 + return ( 11 + <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 12 + <div className="card card-bordered max-w-md bg-base-100 p-8 text-center"> 13 + <h1 className="mb-2 text-lg font-medium text-error"> 14 + Something went wrong 15 + </h1> 16 + <p className="mb-6 text-sm text-text-muted">{error.message}</p> 17 + <button 18 + onClick={() => router.invalidate()} 19 + className="btn btn-neutral btn-sm" 20 + > 21 + Try again 22 + </button> 23 + </div> 24 + </div> 25 + ); 26 + } 27 + 7 28 export const Route = createRootRoute({ 8 29 component: RootLayout, 30 + errorComponent: RootError, 9 31 });
+33 -9
web/src/routes/cabinet.tsx
··· 1 1 import { useState } from "react"; 2 - import { createFileRoute } from "@tanstack/react-router"; 2 + import { createFileRoute, redirect } from "@tanstack/react-router"; 3 3 import { Sidebar } from "@/components/cabinet/Sidebar"; 4 4 import { TopBar } from "@/components/cabinet/TopBar"; 5 5 import { PanelStack } from "@/components/cabinet/PanelStack"; 6 - import type { FileItem, Panel, PanelType } from "@/components/cabinet/types"; 6 + import { useAuthStore } from "@/stores/auth"; 7 + import type { 8 + FileItem, 9 + Panel, 10 + SectionType, 11 + } from "@/components/cabinet/types"; 7 12 8 13 function CabinetPage() { 9 14 const [panels, setPanels] = useState<Panel[]>([ 10 - { id: "root", type: "root", title: "The Cabinet" }, 15 + { type: "root", title: "The Cabinet" }, 11 16 ]); 12 17 const [viewMode, setViewMode] = useState<"list" | "grid">("list"); 13 18 const [searchQuery, setSearchQuery] = useState(""); 14 - const [starred, setStarred] = useState( 15 - new Set(["fi-strategy", "fi-brief", "f-projects", "sh-2"]), 19 + const [starredIds, setStarredIds] = useState( 20 + new Set(["fi-strategy", "fi-brief", "f-projects", "sh-2", "d-thesis"]), 16 21 ); 22 + const [loading, setLoading] = useState(false); 17 23 18 24 const currentPanel = panels[panels.length - 1]; 19 25 20 - const openSection = (type: PanelType, title: string) => { 21 - setPanels([{ id: type, type, title }]); 26 + const openSection = (type: SectionType, title: string) => { 27 + setPanels([{ type, title }]); 22 28 }; 23 29 24 30 const openItem = (item: FileItem) => { 25 31 if (item.kind === "folder") { 26 32 setPanels((prev) => [ 27 33 ...prev, 28 - { id: item.id, type: "folder", title: item.name, data: item }, 34 + { 35 + type: "folder", 36 + folderId: item.id, 37 + title: item.name, 38 + itemCount: item.items, 39 + }, 29 40 ]); 30 41 } 31 42 }; ··· 38 49 setPanels((prev) => prev.slice(0, -1)); 39 50 }; 40 51 52 + // TODO (#3): toggleStar and other callbacks are prop-drilled 4 levels deep 53 + // (cabinet → PanelStack → PanelContent → FileListRow). Extract a 54 + // CabinetContext to provide actions + starredIds via context instead. 41 55 const toggleStar = (id: string) => { 42 - setStarred((prev) => { 56 + setStarredIds((prev) => { 43 57 const next = new Set(prev); 44 58 next.has(id) ? next.delete(id) : next.add(id); 45 59 return next; 46 60 }); 47 61 }; 48 62 63 + // TODO (#4): toggleStar, openItem, goToPanel, closePanel are all redefined 64 + // every render — wrap in useCallback so leaf components can be memoized 65 + // with React.memo(). Alternatively, CabinetContext eliminates the issue. 66 + 49 67 return ( 50 68 <div className="flex h-screen overflow-hidden bg-base-300 font-sans"> 51 69 <Sidebar ··· 62 80 <PanelStack 63 81 panels={panels} 64 82 viewMode={viewMode} 83 + starredIds={starredIds} 84 + loading={loading} 65 85 onViewModeChange={setViewMode} 66 86 onOpenItem={openItem} 67 87 onGoToPanel={goToPanel} ··· 74 94 } 75 95 76 96 export const Route = createFileRoute("/cabinet")({ 97 + beforeLoad: () => { 98 + const { currentDid } = useAuthStore.getState(); 99 + if (!currentDid) throw redirect({ to: "/login" }); 100 + }, 77 101 component: CabinetPage, 78 102 });
+6 -10
web/src/routes/index.tsx
··· 10 10 <OpakeLogo /> 11 11 <Link 12 12 to="/cabinet" 13 - className="btn btn-neutral btn-sm gap-2 text-[13px]" 13 + className="btn btn-neutral btn-sm gap-2 text-ui" 14 14 > 15 15 Open the Cabinet 16 16 <ArrowRight size={14} /> ··· 18 18 </nav> 19 19 20 20 {/* Hero */} 21 - <section className="flex min-h-screen flex-col items-center justify-center px-10 pt-[120px] pb-20"> 21 + <section className="flex min-h-screen flex-col items-center justify-center px-10 pt-30 pb-20"> 22 22 {/* Ornamental rule */} 23 - <div className="mb-8 flex items-center gap-3"> 24 - <div className="h-px w-8 bg-border-accent" /> 25 - <span className="text-[11px] uppercase tracking-[0.18em] text-primary"> 26 - Built on the AT Protocol 27 - </span> 28 - <div className="h-px w-8 bg-border-accent" /> 23 + <div className="divider mb-8 w-80 self-center text-caption uppercase tracking-[0.18em] text-primary before:bg-border-accent after:bg-border-accent"> 24 + Built on the AT Protocol 29 25 </div> 30 26 31 - <h1 className="mb-7 max-w-[820px] text-center font-display text-[clamp(3.4rem,7.5vw,6.2rem)] leading-[1.04] tracking-tight font-normal text-base-content"> 27 + <h1 className="mb-7 max-w-205 text-center font-display text-[clamp(3.4rem,7.5vw,6.2rem)] leading-[1.04] tracking-tight font-normal text-base-content"> 32 28 Your data,{" "} 33 29 <em className="text-primary">freely shared</em>, 34 30 <br /> 35 31 privately kept. 36 32 </h1> 37 33 38 - <p className="mb-10 max-w-[520px] text-center text-[1.05rem] leading-[1.75] text-secondary"> 34 + <p className="mb-10 max-w-130 text-center text-[1.05rem] leading-[1.75] text-secondary"> 39 35 Opake exists because privacy and collaboration should not be a 40 36 tradeoff. Your files — encrypted, owned, shared on your terms — through 41 37 decentralised identity, with no central authority in between.
+46 -4
web/src/routes/login.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { useState } from "react"; 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 + import { OpakeLogo } from "@/components/OpakeLogo"; 4 + import { useAuthStore } from "@/stores/auth"; 2 5 3 6 function LoginPage() { 7 + const navigate = useNavigate(); 8 + const login = useAuthStore((s) => s.login); 9 + const [handle, setHandle] = useState("alice.bsky.social"); 10 + 11 + const handleSubmit = async (e: React.FormEvent) => { 12 + e.preventDefault(); 13 + await login(handle, ""); 14 + navigate({ to: "/cabinet" }); 15 + }; 16 + 4 17 return ( 5 - <div> 6 - <h1 className="text-xl font-semibold">Login</h1> 7 - <p className="mt-2 text-neutral-500">Authentication flow goes here.</p> 18 + <div className="flex min-h-screen flex-col items-center justify-center bg-base-300 font-sans"> 19 + <div className="mb-8"> 20 + <OpakeLogo size="lg" /> 21 + </div> 22 + <form 23 + onSubmit={handleSubmit} 24 + className="card card-bordered w-80 bg-base-100 p-6" 25 + > 26 + <h1 className="mb-1 text-ui font-medium text-base-content"> 27 + Sign in to Opake 28 + </h1> 29 + <p className="mb-5 text-caption text-text-muted"> 30 + Enter your PDS handle to continue. 31 + </p> 32 + <label className="input input-bordered mb-3 flex items-center gap-2"> 33 + <input 34 + type="text" 35 + placeholder="handle.bsky.social" 36 + value={handle} 37 + onChange={(e) => setHandle(e.target.value)} 38 + className="grow" 39 + required 40 + /> 41 + </label> 42 + <button type="submit" className="btn btn-neutral w-full"> 43 + Sign in 44 + </button> 45 + </form> 8 46 </div> 9 47 ); 10 48 } 11 49 12 50 export const Route = createFileRoute("/login")({ 51 + beforeLoad: () => { 52 + const { currentDid } = useAuthStore.getState(); 53 + if (currentDid) throw redirect({ to: "/cabinet" }); 54 + }, 13 55 component: LoginPage, 14 56 });
-26
web/src/stores/documents.ts
··· 1 - import { create } from "zustand"; 2 - 3 - interface Document { 4 - rkey: string; 5 - name: string; 6 - mimeType: string; 7 - size: number; 8 - createdAt: string; 9 - } 10 - 11 - interface DocumentsState { 12 - documents: Document[]; 13 - loading: boolean; 14 - fetch: () => Promise<void>; 15 - } 16 - 17 - export const useDocumentsStore = create<DocumentsState>((set) => ({ 18 - documents: [], 19 - loading: false, 20 - 21 - fetch: async () => { 22 - set({ loading: true }); 23 - // Stub — replaced by real XRPC calls later 24 - set({ documents: [], loading: false }); 25 - }, 26 - }));
+1
web/tsconfig.json
··· 12 12 "isolatedModules": true, 13 13 "resolveJsonModule": true, 14 14 "forceConsistentCasingInFileNames": true, 15 + "types": ["vite/client"], 15 16 "paths": { 16 17 "@/*": ["./src/*"] 17 18 }