An encrypted personal cloud built on the AT Protocol.

Port Figma Make cabinet design into web frontend

+1348 -86
+1
CHANGELOG.md
··· 52 52 - Fix missing HTTP status checks in XRPC client [#104](https://issues.opake.app/issues/104.html) 53 53 54 54 ### Changed 55 + - Port Figma Make cabinet design into web frontend [#165](https://issues.opake.app/issues/165.html) 55 56 - Amend web scaffold into WASM commit [#164](https://issues.opake.app/issues/164.html) 56 57 - Update blackbox tests and docs for new commands [#159](https://issues.opake.app/issues/159.html) 57 58 - Add path-aware mv command [#157](https://issues.opake.app/issues/157.html)
+3
web/bun.lock
··· 20 20 "@types/react": "^19.2.14", 21 21 "@types/react-dom": "^19.2.3", 22 22 "@vitejs/plugin-react": "^5.1.4", 23 + "daisyui": "^5.5.19", 23 24 "happy-dom": "^20.8.3", 24 25 "tailwindcss": "^4.2.1", 25 26 "typescript": "^5.9.3", ··· 332 333 "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], 333 334 334 335 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], 336 + 337 + "daisyui": ["daisyui@5.5.19", "", {}, "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA=="], 335 338 336 339 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 337 340
+7 -1
web/index.html
··· 1 1 <!doctype html> 2 - <html lang="en"> 2 + <html lang="en" data-theme="opake"> 3 3 <head> 4 4 <meta charset="UTF-8" /> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 6 <title>Opake</title> 7 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400;1,500;1,600&family=Inter:ital,opsz,wght@0,14..32,300;0,14..32,400;0,14..32,500;0,14..32,600;1,14..32,300;1,14..32,400&display=swap" 11 + rel="stylesheet" 12 + /> 7 13 </head> 8 14 <body> 9 15 <div id="root"></div>
+1
web/package.json
··· 28 28 "@types/react": "^19.2.14", 29 29 "@types/react-dom": "^19.2.3", 30 30 "@vitejs/plugin-react": "^5.1.4", 31 + "daisyui": "^5.5.19", 31 32 "happy-dom": "^20.8.3", 32 33 "tailwindcss": "^4.2.1", 33 34 "typescript": "^5.9.3",
+34
web/src/components/OpakeLogo.tsx
··· 1 + const SIZES = { 2 + sm: { square: 16, wrap: 22, text: "text-[0.9rem]" }, 3 + md: { square: 22, wrap: 28, text: "text-[1.1rem]" }, 4 + lg: { square: 30, wrap: 36, text: "text-[1.5rem]" }, 5 + } as const; 6 + 7 + type LogoSize = keyof typeof SIZES; 8 + 9 + export function OpakeLogo({ size = "md" }: { size?: LogoSize }) { 10 + const { square, wrap, text } = SIZES[size]; 11 + 12 + return ( 13 + <div className="flex items-center gap-2.5"> 14 + <div 15 + className="relative shrink-0" 16 + style={{ width: wrap, height: wrap }} 17 + > 18 + <div 19 + className="absolute top-0 left-0 rounded-[3px] bg-primary/70" 20 + style={{ width: square, height: square }} 21 + /> 22 + <div 23 + className="absolute right-0 bottom-0 rounded-[3px] border-[1.5px] border-primary/45 bg-primary/20" 24 + style={{ width: square, height: square }} 25 + /> 26 + </div> 27 + <span 28 + className={`font-display font-medium tracking-[0.05em] text-base-content ${text}`} 29 + > 30 + Opake 31 + </span> 32 + </div> 33 + ); 34 + }
+45
web/src/components/cabinet/FileGridCard.tsx
··· 1 + import { Lock } from "@phosphor-icons/react"; 2 + import { StatusBadge } from "./StatusBadge"; 3 + import { fileIconElement, fileIconColors } from "./file-icons"; 4 + import type { FileItem } from "./types"; 5 + 6 + interface FileGridCardProps { 7 + item: FileItem; 8 + onClick: () => void; 9 + } 10 + 11 + export function FileGridCard({ item, onClick }: FileGridCardProps) { 12 + const { bg, text } = fileIconColors(item); 13 + 14 + return ( 15 + <div 16 + onClick={onClick} 17 + 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" : "" 19 + }`} 20 + > 21 + <div className="mb-3 flex items-start justify-between"> 22 + <div 23 + className={`flex size-[38px] items-center justify-center rounded-[10px] ${bg} ${text}`} 24 + > 25 + {fileIconElement(item, 17)} 26 + </div> 27 + <Lock size={11} className="text-text-faint" /> 28 + </div> 29 + 30 + {/* 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)]"> 32 + <div className="absolute inset-0 bg-base-100/55 backdrop-blur-[3px]" /> 33 + <Lock size={13} className="relative z-10 text-text-faint" /> 34 + </div> 35 + 36 + <div className="mb-1.5 truncate text-[12px] text-base-content"> 37 + {item.name} 38 + </div> 39 + <div className="flex items-center justify-between"> 40 + <span className="text-[11px] text-text-faint">{item.modified}</span> 41 + <StatusBadge status={item.status} /> 42 + </div> 43 + </div> 44 + ); 45 + }
+74
web/src/components/cabinet/FileListRow.tsx
··· 1 + import { Star, CaretRight } from "@phosphor-icons/react"; 2 + import { StatusBadge } from "./StatusBadge"; 3 + import { fileIconElement, fileIconColors } from "./file-icons"; 4 + import type { FileItem } from "./types"; 5 + 6 + interface FileListRowProps { 7 + item: FileItem; 8 + onClick: () => void; 9 + onStar: () => void; 10 + } 11 + 12 + export function FileListRow({ item, onClick, onStar }: FileListRowProps) { 13 + const { bg, text } = fileIconColors(item); 14 + 15 + return ( 16 + <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" : "" 20 + }`} 21 + > 22 + {/* Icon */} 23 + <div 24 + className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${bg} ${text}`} 25 + > 26 + {fileIconElement(item, 15)} 27 + </div> 28 + 29 + {/* Name + meta */} 30 + <div className="min-w-0 flex-1"> 31 + <div className="truncate text-[13px] text-base-content"> 32 + {item.name} 33 + </div> 34 + <div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-text-faint"> 35 + <span>{item.modified}</span> 36 + {item.size && ( 37 + <> 38 + <span>·</span> 39 + <span>{item.size}</span> 40 + </> 41 + )} 42 + {item.items !== undefined && ( 43 + <> 44 + <span>·</span> 45 + <span>{item.items} items</span> 46 + </> 47 + )} 48 + </div> 49 + </div> 50 + 51 + {/* Status + actions */} 52 + <div className="flex shrink-0 items-center gap-2"> 53 + <StatusBadge status={item.status} /> 54 + <button 55 + onClick={(e) => { 56 + e.stopPropagation(); 57 + onStar(); 58 + }} 59 + className={`btn btn-ghost btn-xs p-0.5 ${ 60 + item.starred ? "text-warning" : "text-text-faint" 61 + }`} 62 + > 63 + <Star 64 + size={13} 65 + weight={item.starred ? "fill" : "regular"} 66 + /> 67 + </button> 68 + {item.kind === "folder" && ( 69 + <CaretRight size={13} className="text-text-faint" /> 70 + )} 71 + </div> 72 + </div> 73 + ); 74 + }
+208
web/src/components/cabinet/PanelContent.tsx
··· 1 + import { 2 + Sparkle, 3 + Lock, 4 + ShareNetwork, 5 + Graph, 6 + Question, 7 + ArrowSquareOut, 8 + User, 9 + ShieldCheck, 10 + Bell, 11 + CaretRight, 12 + Trash, 13 + Folder, 14 + } from "@phosphor-icons/react"; 15 + import { FileListRow } from "./FileListRow"; 16 + import { FileGridCard } from "./FileGridCard"; 17 + import type { FileItem, Panel } from "./types"; 18 + import { 19 + ROOT_ITEMS, 20 + SHARED_ITEMS, 21 + STARRED_ITEMS, 22 + DOCUMENTS_ITEMS, 23 + } from "./mock-data"; 24 + 25 + const DOCS_SECTIONS = [ 26 + { id: "getting-started", title: "Getting Started", icon: Sparkle, desc: "Set up your cabinet, create your first encrypted file, and explore the interface." }, 27 + { id: "encryption", title: "Encryption & Keys", icon: Lock, desc: "How end-to-end encryption works in Opake and how your keys are managed." }, 28 + { id: "sharing", title: "Sharing & DIDs", icon: ShareNetwork, desc: "Share files using decentralised identifiers without a central authority." }, 29 + { id: "at-protocol", title: "AT Protocol", icon: Graph, desc: "The open standard powering Opake — identity, data portability, and federation." }, 30 + { id: "faq", title: "FAQ", icon: Question, desc: "Common questions about privacy, security, and how Opake compares to alternatives." }, 31 + ]; 32 + 33 + const SETTINGS_SECTIONS = [ 34 + { label: "Account & Identity", desc: "DID: did:plc:7f2ab3c4d…8e91f0", icon: User }, 35 + { label: "Encryption Keys", desc: "Last rotated 14 days ago · Active", icon: Lock }, 36 + { label: "Sharing & Permissions", desc: "3 active collaborators", icon: ShareNetwork }, 37 + { label: "Connected Devices", desc: "2 devices linked", icon: ShieldCheck }, 38 + { label: "Notifications", desc: "Email & in-app alerts", icon: Bell }, 39 + ]; 40 + 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 + } 58 + } 59 + 60 + interface PanelContentProps { 61 + panel: Panel; 62 + viewMode: "list" | "grid"; 63 + onOpen: (item: FileItem) => void; 64 + onStar: (id: string) => void; 65 + } 66 + 67 + export function PanelContent({ 68 + panel, 69 + viewMode, 70 + onOpen, 71 + onStar, 72 + }: PanelContentProps) { 73 + // Docs 74 + if (panel.type === "docs") { 75 + return ( 76 + <div className="p-5"> 77 + <div className="mb-5"> 78 + <div className="mb-1 text-[13px] font-medium text-base-content"> 79 + Documentation 80 + </div> 81 + <div className="text-[12px] text-text-muted"> 82 + Everything you need to get the most out of Opake. 83 + </div> 84 + </div> 85 + <div className="flex flex-col gap-2"> 86 + {DOCS_SECTIONS.map((s) => ( 87 + <div 88 + 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" 90 + > 91 + <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent"> 92 + <s.icon size={14} className="text-primary" /> 93 + </div> 94 + <div className="flex-1"> 95 + <div className="mb-0.5 text-[13px] font-medium text-base-content"> 96 + {s.title} 97 + </div> 98 + <div className="text-[11px] leading-relaxed text-text-muted"> 99 + {s.desc} 100 + </div> 101 + </div> 102 + <ArrowSquareOut 103 + size={12} 104 + className="mt-0.5 shrink-0 text-text-faint" 105 + /> 106 + </div> 107 + ))} 108 + </div> 109 + </div> 110 + ); 111 + } 112 + 113 + // Settings 114 + if (panel.type === "settings") { 115 + return ( 116 + <div className="p-5"> 117 + <div className="mb-5"> 118 + <div className="mb-1 text-[13px] font-medium text-base-content"> 119 + Settings 120 + </div> 121 + <div className="text-[12px] text-text-muted"> 122 + Manage your account, keys, and preferences. 123 + </div> 124 + </div> 125 + <div className="divider mt-0 mb-4" /> 126 + <div className="flex flex-col gap-1.5"> 127 + {SETTINGS_SECTIONS.map(({ label, desc, icon: Icon }) => ( 128 + <div 129 + key={label} 130 + className="flex cursor-pointer items-center gap-3 rounded-[10px] border border-base-300/50 bg-base-100 p-3.5" 131 + > 132 + <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-stone"> 133 + <Icon size={14} className="text-text-muted" /> 134 + </div> 135 + <div className="flex-1"> 136 + <div className="text-[13px] font-medium text-base-content"> 137 + {label} 138 + </div> 139 + <div className="text-[11px] text-text-muted">{desc}</div> 140 + </div> 141 + <CaretRight size={13} className="text-text-faint" /> 142 + </div> 143 + ))} 144 + </div> 145 + </div> 146 + ); 147 + } 148 + 149 + // Trash 150 + if (panel.type === "trash") { 151 + return ( 152 + <div className="hero py-16"> 153 + <div className="hero-content flex-col text-center"> 154 + <div className="flex size-[52px] items-center justify-center rounded-[14px] bg-bg-stone"> 155 + <Trash size={22} className="text-text-faint" /> 156 + </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"> 159 + Deleted files appear here for 30 days before permanent removal. 160 + </div> 161 + </div> 162 + </div> 163 + ); 164 + } 165 + 166 + // File browser (list / grid) 167 + const items = getItemsForPanel(panel); 168 + 169 + if (items.length === 0) { 170 + return ( 171 + <div className="hero py-16"> 172 + <div className="hero-content flex-col text-center"> 173 + <div className="flex size-[52px] items-center justify-center rounded-[14px] bg-accent"> 174 + <Folder size={22} className="text-text-faint" /> 175 + </div> 176 + <div className="text-[13px] text-text-muted">Nothing here yet</div> 177 + </div> 178 + </div> 179 + ); 180 + } 181 + 182 + return ( 183 + <div className="p-3"> 184 + {viewMode === "list" ? ( 185 + <div className="flex flex-col gap-px"> 186 + {items.map((item) => ( 187 + <FileListRow 188 + key={item.id} 189 + item={item} 190 + onClick={() => item.kind === "folder" && onOpen(item)} 191 + onStar={() => onStar(item.id)} 192 + /> 193 + ))} 194 + </div> 195 + ) : ( 196 + <div className="grid grid-cols-2 gap-3"> 197 + {items.map((item) => ( 198 + <FileGridCard 199 + key={item.id} 200 + item={item} 201 + onClick={() => item.kind === "folder" && onOpen(item)} 202 + /> 203 + ))} 204 + </div> 205 + )} 206 + </div> 207 + ); 208 + }
+293
web/src/components/cabinet/PanelStack.tsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { 3 + CaretRight, 4 + ListBullets, 5 + SquaresFour, 6 + Plus, 7 + X, 8 + UploadSimple, 9 + Folder, 10 + FileText, 11 + BookOpen, 12 + Clock, 13 + ShieldCheck, 14 + Users, 15 + Lock, 16 + } from "@phosphor-icons/react"; 17 + import { PanelContent } from "./PanelContent"; 18 + import { fileIconElement, fileIconColors } from "./file-icons"; 19 + import { ROOT_ITEMS, SHARED_ITEMS, STARRED_ITEMS } from "./mock-data"; 20 + import type { FileItem, Panel } from "./types"; 21 + 22 + const FILE_BROWSER_TYPES = new Set([ 23 + "root", 24 + "folder", 25 + "shared", 26 + "starred", 27 + "encrypted", 28 + ]); 29 + 30 + interface PanelStackProps { 31 + panels: Panel[]; 32 + viewMode: "list" | "grid"; 33 + onViewModeChange: (mode: "list" | "grid") => void; 34 + onOpenItem: (item: FileItem) => void; 35 + onGoToPanel: (index: number) => void; 36 + onClosePanel: () => void; 37 + onStar: (id: string) => void; 38 + } 39 + 40 + export function PanelStack({ 41 + panels, 42 + viewMode, 43 + onViewModeChange, 44 + onOpenItem, 45 + onGoToPanel, 46 + onClosePanel, 47 + onStar, 48 + }: 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 + const currentPanel = panels[panels.length - 1]; 63 + const depth = panels.length; 64 + const isFileBrowser = FILE_BROWSER_TYPES.has(currentPanel.type); 65 + 66 + const footerText = (() => { 67 + switch (currentPanel.type) { 68 + case "root": 69 + return `${ROOT_ITEMS.length} items · All encrypted · AT Protocol`; 70 + case "shared": 71 + return `${SHARED_ITEMS.length} shared items · Encrypted`; 72 + case "starred": 73 + return `${STARRED_ITEMS.length} starred items`; 74 + case "encrypted": 75 + return `${ROOT_ITEMS.filter((i) => i.status === "private").length} private items`; 76 + case "folder": 77 + return `${currentPanel.data?.items ?? "–"} items · Encrypted`; 78 + case "docs": 79 + return "Documentation · Opake"; 80 + case "settings": 81 + return "Account settings"; 82 + case "trash": 83 + return "Trash · 30 day retention"; 84 + } 85 + })(); 86 + 87 + return ( 88 + <div className="relative flex-1 overflow-hidden p-[22px] pl-7"> 89 + {/* Ghost panels — filing cabinet depth */} 90 + {depth >= 3 && ( 91 + <div className="absolute inset-y-[22px] right-[22px] left-7 z-[1] -translate-x-2.5 -translate-y-2.5 rounded-2xl border border-primary/15 bg-bg-ghost-1" /> 92 + )} 93 + {depth >= 2 && ( 94 + <div className="absolute inset-y-[22px] right-[22px] left-7 z-[2] -translate-x-[5px] -translate-y-[5px] rounded-2xl border border-base-300/50 bg-bg-ghost-2 shadow-panel-sm" /> 95 + )} 96 + 97 + {/* Active panel */} 98 + <div className="absolute inset-y-[22px] right-[22px] left-7 z-10 flex flex-col overflow-hidden rounded-2xl border border-base-300/50 bg-base-100 shadow-panel-lg"> 99 + {/* Panel header */} 100 + <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 + {/* Breadcrumb */} 102 + <div className="breadcrumbs min-w-0 flex-1 overflow-hidden text-[13px]"> 103 + <ul> 104 + {panels.map((panel, i) => ( 105 + <li key={panel.id}> 106 + <button 107 + onClick={() => onGoToPanel(i)} 108 + className={ 109 + i === panels.length - 1 110 + ? "font-medium text-base-content" 111 + : "text-text-faint" 112 + } 113 + > 114 + {panel.title} 115 + </button> 116 + </li> 117 + ))} 118 + </ul> 119 + </div> 120 + 121 + {/* Toolbar */} 122 + <div className="flex shrink-0 items-center gap-2"> 123 + {/* View toggle */} 124 + {isFileBrowser && ( 125 + <div className="join rounded-lg bg-primary/10 p-0.5"> 126 + <button 127 + onClick={() => onViewModeChange("list")} 128 + className={`join-item btn btn-xs rounded-md border-0 ${ 129 + viewMode === "list" 130 + ? "bg-base-100 text-secondary shadow-panel-sm" 131 + : "bg-transparent text-text-faint" 132 + }`} 133 + > 134 + <ListBullets size={13} /> 135 + </button> 136 + <button 137 + onClick={() => onViewModeChange("grid")} 138 + className={`join-item btn btn-xs rounded-md border-0 ${ 139 + viewMode === "grid" 140 + ? "bg-base-100 text-secondary shadow-panel-sm" 141 + : "bg-transparent text-text-faint" 142 + }`} 143 + > 144 + <SquaresFour size={13} /> 145 + </button> 146 + </div> 147 + )} 148 + 149 + {/* 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 + > 155 + <Plus size={13} /> 156 + 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 }) => ( 166 + <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" 170 + > 171 + <Icon size={13} className="text-text-muted" /> 172 + {label} 173 + </button> 174 + ))} 175 + </div> 176 + )} 177 + </div> 178 + 179 + {/* Close panel */} 180 + {depth > 1 && ( 181 + <button 182 + onClick={onClosePanel} 183 + className="btn btn-ghost btn-sm btn-square rounded-[7px]" 184 + > 185 + <X size={14} className="text-text-muted" /> 186 + </button> 187 + )} 188 + </div> 189 + </div> 190 + 191 + {/* Panel body */} 192 + <div className="min-h-0 flex-1 overflow-y-auto"> 193 + {/* Recent bar — root list only */} 194 + {currentPanel.type === "root" && viewMode === "list" && ( 195 + <div className="px-4 pt-4"> 196 + <div className="mb-3 flex items-center gap-[7px]"> 197 + <Clock size={12} className="text-text-faint" /> 198 + <span className="text-[10px] uppercase tracking-[0.1em] text-text-faint"> 199 + Recent 200 + </span> 201 + </div> 202 + <div className="flex gap-2 overflow-x-auto pb-3 [scrollbar-width:none]"> 203 + {ROOT_ITEMS.slice(5, 9).map((item) => { 204 + const { bg, text } = fileIconColors(item); 205 + return ( 206 + <div 207 + key={`r-${item.id}`} 208 + className="w-[130px] shrink-0 cursor-pointer rounded-[10px] border border-base-300/50 bg-base-100 p-3" 209 + > 210 + <div 211 + className={`mb-2 flex size-[26px] items-center justify-center rounded-[7px] ${bg} ${text}`} 212 + > 213 + {fileIconElement(item, 13)} 214 + </div> 215 + <div className="truncate text-[11px] text-base-content"> 216 + {item.name} 217 + </div> 218 + <div className="mt-0.5 text-[10px] text-text-faint"> 219 + {item.modified} 220 + </div> 221 + </div> 222 + ); 223 + })} 224 + </div> 225 + {/* 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" /> 232 + </div> 233 + </div> 234 + )} 235 + 236 + {/* Section notices */} 237 + {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"> 239 + <Users 240 + size={13} 241 + className="mt-0.5 shrink-0 text-success" 242 + /> 243 + <div> 244 + <div className="mb-0.5 text-[12px] font-medium text-success"> 245 + Shared via decentralised identity 246 + </div> 247 + <div className="text-[11px] leading-relaxed text-success/80"> 248 + Files shared via DID. Encrypted in transit and at rest — only 249 + invited parties can decrypt. 250 + </div> 251 + </div> 252 + </div> 253 + )} 254 + {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"> 256 + <Lock 257 + size={13} 258 + className="mt-0.5 shrink-0 text-primary" 259 + /> 260 + <div> 261 + <div className="mb-0.5 text-[12px] font-medium text-accent-content"> 262 + Private encrypted files 263 + </div> 264 + <div className="text-[11px] leading-relaxed text-primary"> 265 + Only you can decrypt these files. Not shared with anyone. 266 + </div> 267 + </div> 268 + </div> 269 + )} 270 + 271 + <PanelContent 272 + panel={currentPanel} 273 + viewMode={viewMode} 274 + onOpen={onOpenItem} 275 + onStar={onStar} 276 + /> 277 + </div> 278 + 279 + {/* Panel footer */} 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 + <ShieldCheck size={11} className="text-primary" /> 282 + <span className="text-[11px] text-text-faint">{footerText}</span> 283 + <div className="flex-1" /> 284 + {depth > 1 && ( 285 + <span className="font-display text-[13px] italic text-text-faint"> 286 + {depth} panels open 287 + </span> 288 + )} 289 + </div> 290 + </div> 291 + </div> 292 + ); 293 + }
+116
web/src/components/cabinet/Sidebar.tsx
··· 1 + import { 2 + Folder, 3 + Lock, 4 + Users, 5 + Star, 6 + BookOpen, 7 + Trash, 8 + Gear, 9 + } from "@phosphor-icons/react"; 10 + import { Link } from "@tanstack/react-router"; 11 + import { OpakeLogo } from "../OpakeLogo"; 12 + import { SidebarItem } from "./SidebarItem"; 13 + import type { PanelType } from "./types"; 14 + 15 + const MAIN_NAV = [ 16 + { type: "root" as const, icon: Folder, label: "The Cabinet" }, 17 + { type: "encrypted" as const, icon: Lock, label: "Encrypted" }, 18 + { type: "shared" as const, icon: Users, label: "Shared with me", badge: "4" }, 19 + { type: "starred" as const, icon: Star, label: "Starred" }, 20 + ]; 21 + 22 + const BOTTOM_NAV = [ 23 + { type: "docs" as const, icon: BookOpen, label: "Docs & Help" }, 24 + { type: "trash" as const, icon: Trash, label: "Trash" }, 25 + { type: "settings" as const, icon: Gear, label: "Settings" }, 26 + ]; 27 + 28 + const WORKSPACES = [ 29 + { id: "ws-personal", name: "Personal", count: 3 }, 30 + { id: "ws-team", name: "Team Alpha", count: 2 }, 31 + ]; 32 + 33 + interface SidebarProps { 34 + activePanelType: PanelType; 35 + panelDepth: number; 36 + onOpenSection: (type: PanelType, title: string) => void; 37 + } 38 + 39 + export function Sidebar({ 40 + activePanelType, 41 + panelDepth, 42 + onOpenSection, 43 + }: SidebarProps) { 44 + return ( 45 + <aside className="flex w-[212px] shrink-0 flex-col border-r border-base-300/50 bg-base-200 px-3 py-4"> 46 + {/* Logo */} 47 + <div className="mb-5 px-0.5"> 48 + <Link to="/" className="inline-block"> 49 + <OpakeLogo /> 50 + </Link> 51 + </div> 52 + 53 + {/* Storage */} 54 + <div className="mb-5 px-1"> 55 + <div className="mb-1.5 flex justify-between text-[11px] text-text-faint"> 56 + <span>Storage</span> 57 + <span>3.1 / 10 GB</span> 58 + </div> 59 + <progress 60 + className="progress progress-primary h-[3px] w-full" 61 + value={31} 62 + max={100} 63 + /> 64 + </div> 65 + 66 + <div className="divider my-0 mx-1" /> 67 + 68 + {/* Main nav */} 69 + <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto"> 70 + {MAIN_NAV.map(({ type, icon, label, badge }) => ( 71 + <SidebarItem 72 + key={type} 73 + icon={icon} 74 + label={label} 75 + badge={badge} 76 + active={activePanelType === type && panelDepth === 1} 77 + onClick={() => onOpenSection(type, label)} 78 + /> 79 + ))} 80 + 81 + {/* Workspaces */} 82 + <div className="mt-3.5 mb-1.5 ml-1 text-[10px] uppercase tracking-[0.1em] text-text-faint"> 83 + Workspaces 84 + </div> 85 + {WORKSPACES.map((ws) => ( 86 + <button 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" 89 + > 90 + <div className="flex size-5 shrink-0 items-center justify-center rounded-[5px] bg-accent text-[9px] font-semibold text-primary"> 91 + {ws.name[0]} 92 + </div> 93 + <span className="flex-1">{ws.name}</span> 94 + <span className="text-[10px] text-text-faint">{ws.count}</span> 95 + </button> 96 + ))} 97 + </nav> 98 + 99 + {/* Bottom nav */} 100 + <div> 101 + <div className="divider my-0 mx-1" /> 102 + <div className="flex flex-col gap-0.5"> 103 + {BOTTOM_NAV.map(({ type, icon, label }) => ( 104 + <SidebarItem 105 + key={type} 106 + icon={icon} 107 + label={label} 108 + active={activePanelType === type} 109 + onClick={() => onOpenSection(type, label)} 110 + /> 111 + ))} 112 + </div> 113 + </div> 114 + </aside> 115 + ); 116 + }
+45
web/src/components/cabinet/SidebarItem.tsx
··· 1 + import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; 2 + 3 + interface SidebarItemProps { 4 + icon: PhosphorIcon; 5 + label: string; 6 + active: boolean; 7 + badge?: string | number; 8 + onClick: () => void; 9 + } 10 + 11 + export function SidebarItem({ 12 + icon: Icon, 13 + label, 14 + active, 15 + badge, 16 + onClick, 17 + }: SidebarItemProps) { 18 + return ( 19 + <button 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 ${ 22 + active 23 + ? "bg-accent text-primary" 24 + : "text-text-muted hover:bg-bg-hover" 25 + }`} 26 + > 27 + <Icon 28 + size={14} 29 + weight={active ? "fill" : "regular"} 30 + /> 31 + <span className="flex-1">{label}</span> 32 + {badge !== undefined && ( 33 + <span 34 + className={`rounded-[5px] px-1.5 py-px text-[10px] ${ 35 + active 36 + ? "bg-primary/20 text-primary" 37 + : "bg-primary/10 text-text-muted" 38 + }`} 39 + > 40 + {badge} 41 + </span> 42 + )} 43 + </button> 44 + ); 45 + }
+37
web/src/components/cabinet/StatusBadge.tsx
··· 1 + import { Lock, Users, Globe } from "@phosphor-icons/react"; 2 + import type { EncStatus } from "./types"; 3 + 4 + const VARIANTS: Record< 5 + EncStatus, 6 + { className: string; icon: typeof Lock; label: string } 7 + > = { 8 + private: { 9 + className: "badge-accent text-primary border-border-accent", 10 + icon: Lock, 11 + label: "Private", 12 + }, 13 + shared: { 14 + className: "bg-bg-sage text-success border-success/30", 15 + icon: Users, 16 + label: "Shared", 17 + }, 18 + public: { 19 + className: "bg-bg-stone text-text-muted border-base-300", 20 + icon: Globe, 21 + label: "Public", 22 + }, 23 + }; 24 + 25 + export function StatusBadge({ status }: { status: EncStatus }) { 26 + const variant = VARIANTS[status]; 27 + const Icon = variant.icon; 28 + 29 + return ( 30 + <span 31 + className={`badge badge-sm gap-1 border text-[10px] tracking-wide ${variant.className}`} 32 + > 33 + <Icon size={8} weight="bold" /> 34 + {variant.label} 35 + </span> 36 + ); 37 + }
+132
web/src/components/cabinet/TopBar.tsx
··· 1 + import { useState, useRef, useEffect } from "react"; 2 + import { 3 + MagnifyingGlass, 4 + X, 5 + ShieldCheck, 6 + Bell, 7 + User, 8 + Lock, 9 + Gear, 10 + SignOut, 11 + ShareNetwork, 12 + } from "@phosphor-icons/react"; 13 + import { Link } from "@tanstack/react-router"; 14 + 15 + interface TopBarProps { 16 + searchQuery: string; 17 + onSearchChange: (query: string) => void; 18 + onOpenSettings: () => void; 19 + } 20 + 21 + export function TopBar({ 22 + searchQuery, 23 + onSearchChange, 24 + onOpenSettings, 25 + }: 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 + return ( 43 + <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 + {/* 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]"> 46 + <MagnifyingGlass size={13} className="text-text-faint" /> 47 + <input 48 + type="text" 49 + placeholder="Search your cabinet…" 50 + value={searchQuery} 51 + onChange={(e) => onSearchChange(e.target.value)} 52 + className="grow bg-transparent text-secondary" 53 + /> 54 + {searchQuery && ( 55 + <button 56 + onClick={() => onSearchChange("")} 57 + className="btn btn-ghost btn-xs p-0 text-text-faint" 58 + > 59 + <X size={12} /> 60 + </button> 61 + )} 62 + </label> 63 + 64 + <div className="flex-1" /> 65 + 66 + {/* 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"> 68 + <ShieldCheck size={12} weight="bold" /> 69 + End-to-end encrypted 70 + </div> 71 + 72 + {/* 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> 77 + 78 + {/* 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"> 85 + A 86 + </div> 87 + <span className="text-[12px] font-normal text-secondary"> 88 + alice.bsky.social 89 + </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> 101 + </div> 102 + {[ 103 + { icon: User, label: "Profile & DID" }, 104 + { icon: Lock, label: "Encryption Keys" }, 105 + { icon: Gear, label: "Settings" }, 106 + ].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> 118 + ))} 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> 130 + </header> 131 + ); 132 + }
+47
web/src/components/cabinet/file-icons.tsx
··· 1 + import { 2 + Folder, 3 + FileText, 4 + File, 5 + BookOpen, 6 + Archive, 7 + } from "@phosphor-icons/react"; 8 + import type { FileItem } from "./types"; 9 + 10 + interface IconStyle { 11 + bg: string; 12 + text: string; 13 + } 14 + 15 + const FOLDER_STYLE: IconStyle = { bg: "bg-accent", text: "text-primary" }; 16 + 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]" }, 23 + archive: { bg: "bg-bg-stone", text: "text-text-muted" }, 24 + }; 25 + 26 + const DEFAULT_STYLE: IconStyle = { bg: "bg-bg-stone", text: "text-text-muted" }; 27 + 28 + export function fileIconColors(item: FileItem): IconStyle { 29 + if (item.kind === "folder") return FOLDER_STYLE; 30 + return FILE_TYPE_STYLES[item.fileType ?? ""] ?? DEFAULT_STYLE; 31 + } 32 + 33 + export function fileIconElement(item: FileItem, size = 15) { 34 + if (item.kind === "folder") return <Folder size={size} weight="fill" />; 35 + switch (item.fileType) { 36 + case "document": 37 + return <FileText size={size} />; 38 + case "spreadsheet": 39 + return <File size={size} />; 40 + case "note": 41 + return <BookOpen size={size} />; 42 + case "archive": 43 + return <Archive size={size} />; 44 + default: 45 + return <File size={size} />; 46 + } 47 + }
+31
web/src/components/cabinet/mock-data.ts
··· 1 + import type { FileItem } from "./types"; 2 + 3 + export const ROOT_ITEMS: FileItem[] = [ 4 + { id: "f-documents", name: "Documents", kind: "folder", encrypted: true, status: "private", items: 23, modified: "2 hours ago", starred: false }, 5 + { id: "f-projects", name: "Projects", kind: "folder", encrypted: true, status: "shared", sharedWith: ["alice.did", "bob.did"], items: 7, modified: "Yesterday", starred: true }, 6 + { id: "f-photos", name: "Photos", kind: "folder", encrypted: true, status: "private", items: 156, modified: "3 days ago", starred: false }, 7 + { id: "f-notes", name: "Notes", kind: "folder", encrypted: true, status: "private", items: 44, modified: "Just now", starred: false }, 8 + { id: "f-archive", name: "Archive", kind: "folder", encrypted: true, status: "private", items: 12, modified: "1 week ago", starred: false }, 9 + { id: "fi-strategy", name: "Q4 Strategy.doc", kind: "file", fileType: "document", encrypted: true, status: "private", size: "245 KB", modified: "2 hours ago", starred: true }, 10 + { id: "fi-budget", name: "Budget 2026.xlsx", kind: "file", fileType: "spreadsheet", encrypted: true, status: "shared", sharedWith: ["carol.did"], size: "1.2 MB", modified: "3 days ago", starred: false }, 11 + { id: "fi-brief", name: "Design Brief.pdf", kind: "file", fileType: "pdf", encrypted: true, status: "private", size: "3.4 MB", modified: "Yesterday", starred: true }, 12 + { id: "fi-notes", name: "Team Notes.md", kind: "file", fileType: "note", encrypted: true, status: "shared", sharedWith: ["alice.did", "bob.did", "carol.did"], size: "18 KB", modified: "Just now", starred: false }, 13 + { id: "fi-api", name: "API Contracts.json", kind: "file", fileType: "code", encrypted: true, status: "private", size: "67 KB", modified: "1 week ago", starred: false }, 14 + ]; 15 + 16 + export const DOCUMENTS_ITEMS: FileItem[] = [ 17 + { id: "d-reports", name: "Reports", kind: "folder", encrypted: true, status: "private", items: 8, modified: "1 week ago", starred: false }, 18 + { id: "d-contracts", name: "Contracts", kind: "folder", encrypted: true, status: "shared", sharedWith: ["legal.did"], items: 5, modified: "2 weeks ago", starred: false }, 19 + { id: "d-thesis", name: "Thesis Draft v4.doc", kind: "file", fileType: "document", encrypted: true, status: "private", size: "1.8 MB", modified: "3 days ago", starred: true }, 20 + { id: "d-cv", name: "CV 2026.pdf", kind: "file", fileType: "pdf", encrypted: true, status: "private", size: "340 KB", modified: "1 month ago", starred: false }, 21 + { id: "d-ref", name: "Reference Notes.md", kind: "file", fileType: "note", encrypted: true, status: "private", size: "88 KB", modified: "5 days ago", starred: false }, 22 + ]; 23 + 24 + export const SHARED_ITEMS: FileItem[] = [ 25 + { id: "sh-1", name: "Product Roadmap.doc", kind: "file", fileType: "document", encrypted: true, status: "shared", sharedWith: ["team.did"], size: "512 KB", modified: "1 hour ago", starred: false }, 26 + { id: "sh-2", name: "Sprint Board", kind: "folder", encrypted: true, status: "shared", sharedWith: ["alice.did", "bob.did", "carol.did"], items: 9, modified: "30 min ago", starred: true }, 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 + { 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 + ]; 30 + 31 + export const STARRED_ITEMS = ROOT_ITEMS.filter((i) => i.starred);
+41
web/src/components/cabinet/types.ts
··· 1 + export type EncStatus = "private" | "shared" | "public"; 2 + 3 + export type FileType = 4 + | "document" 5 + | "spreadsheet" 6 + | "pdf" 7 + | "image" 8 + | "code" 9 + | "note" 10 + | "archive"; 11 + 12 + export interface FileItem { 13 + id: string; 14 + name: string; 15 + kind: "file" | "folder"; 16 + fileType?: FileType; 17 + encrypted: boolean; 18 + status: EncStatus; 19 + sharedWith?: string[]; 20 + size?: string; 21 + items?: number; 22 + modified: string; 23 + starred: boolean; 24 + } 25 + 26 + export type PanelType = 27 + | "root" 28 + | "folder" 29 + | "shared" 30 + | "starred" 31 + | "encrypted" 32 + | "docs" 33 + | "trash" 34 + | "settings"; 35 + 36 + export interface Panel { 37 + id: string; 38 + type: PanelType; 39 + title: string; 40 + data?: FileItem; 41 + }
+74 -1
web/src/index.css
··· 1 1 @import "tailwindcss"; 2 + @plugin "daisyui"; 3 + 4 + /* ─── Opake theme ─────────────────────────────────────────────────────────── */ 5 + 6 + @plugin "daisyui/theme" { 7 + name: "opake"; 8 + default: true; 9 + 10 + /* Surfaces */ 11 + --color-base-100: oklch(0.985 0.005 85); /* #FDFAF5 — panel bg */ 12 + --color-base-200: oklch(0.957 0.012 85); /* #F5F1E8 — sidebar bg */ 13 + --color-base-300: oklch(0.930 0.015 85); /* #EDE8DC — page bg */ 14 + --color-base-content: oklch(0.155 0.035 70); /* #1C1408 — primary text */ 15 + 16 + /* Accent gold */ 17 + --color-primary: oklch(0.580 0.095 75); /* #9A7840 */ 18 + --color-primary-content: oklch(0.976 0.008 85); /* #FAF7F1 */ 19 + 20 + /* Muted text / secondary */ 21 + --color-secondary: oklch(0.390 0.055 65); /* #5C4A2E */ 22 + --color-secondary-content: oklch(0.976 0.008 85); /* #FAF7F1 */ 23 + 24 + /* Accent light bg */ 25 + --color-accent: oklch(0.940 0.035 85); /* #F5E9D0 */ 26 + --color-accent-content: oklch(0.490 0.080 70); /* #7D6230 */ 27 + 28 + /* Dark UI */ 29 + --color-neutral: oklch(0.155 0.035 70); /* #1C1408 */ 30 + --color-neutral-content: oklch(0.976 0.008 85); /* #FAF7F1 */ 31 + 32 + /* Semantic */ 33 + --color-success: oklch(0.530 0.065 145); /* #5C7A54 — shared/sage */ 34 + --color-success-content: oklch(0.976 0.008 85); 35 + --color-warning: oklch(0.680 0.135 75); /* #C4952A — gold star */ 36 + --color-warning-content: oklch(0.155 0.035 70); 37 + --color-error: oklch(0.450 0.110 25); /* #A04840 — destructive */ 38 + --color-error-content: oklch(0.976 0.008 85); 39 + --color-info: oklch(0.580 0.095 75); 40 + --color-info-content: oklch(0.976 0.008 85); 41 + 42 + /* Shape */ 43 + --radius-selector: 0.625rem; 44 + --radius-field: 0.5625rem; 45 + --radius-box: 1rem; 46 + --border: 1px; 47 + --depth: 1; 48 + --noise: 0; 49 + } 50 + 51 + /* ─── Design tokens ───────────────────────────────────────────────────────── */ 2 52 3 53 @theme { 4 - /* Populated from Figma design tokens later */ 54 + --font-display: "Cormorant Garamond", serif; 55 + --font-sans: "Inter", sans-serif; 56 + 57 + --color-border-accent: oklch(0.790 0.060 80); /* #D4BC96 */ 58 + --color-text-faint: oklch(0.760 0.035 75); /* #C4B09A */ 59 + --color-text-muted: oklch(0.620 0.040 70); /* #9A8768 */ 60 + --color-bg-hover: oklch(0.580 0.095 75 / 0.055); /* gold hover */ 61 + --color-bg-ghost-1: oklch(0.940 0.025 80); /* #F5EDDB */ 62 + --color-bg-ghost-2: oklch(0.965 0.015 80); /* #FAF5EC */ 63 + --color-bg-sage: oklch(0.940 0.020 140); /* #EEF2E8 */ 64 + --color-bg-stone: oklch(0.950 0.010 80); /* #F4F1EC */ 65 + 66 + --shadow-panel-sm: 0 1px 8px oklch(0.35 0.05 60 / 0.07); 67 + --shadow-panel-md: 0 2px 16px oklch(0.35 0.05 60 / 0.09); 68 + --shadow-panel-lg: 0 6px 32px oklch(0.35 0.05 60 / 0.12); 69 + } 70 + 71 + /* ─── Base styles ─────────────────────────────────────────────────────────── */ 72 + 73 + @layer base { 74 + body { 75 + font-family: var(--font-sans); 76 + -webkit-font-smoothing: antialiased; 77 + } 5 78 }
+21 -21
web/src/routeTree.gen.ts
··· 9 9 // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 10 11 11 import { Route as rootRouteImport } from './routes/__root' 12 - import { Route as SharedRouteImport } from './routes/shared' 13 12 import { Route as LoginRouteImport } from './routes/login' 13 + import { Route as CabinetRouteImport } from './routes/cabinet' 14 14 import { Route as IndexRouteImport } from './routes/index' 15 15 16 - const SharedRoute = SharedRouteImport.update({ 17 - id: '/shared', 18 - path: '/shared', 19 - getParentRoute: () => rootRouteImport, 20 - } as any) 21 16 const LoginRoute = LoginRouteImport.update({ 22 17 id: '/login', 23 18 path: '/login', 24 19 getParentRoute: () => rootRouteImport, 25 20 } as any) 21 + const CabinetRoute = CabinetRouteImport.update({ 22 + id: '/cabinet', 23 + path: '/cabinet', 24 + getParentRoute: () => rootRouteImport, 25 + } as any) 26 26 const IndexRoute = IndexRouteImport.update({ 27 27 id: '/', 28 28 path: '/', ··· 31 31 32 32 export interface FileRoutesByFullPath { 33 33 '/': typeof IndexRoute 34 + '/cabinet': typeof CabinetRoute 34 35 '/login': typeof LoginRoute 35 - '/shared': typeof SharedRoute 36 36 } 37 37 export interface FileRoutesByTo { 38 38 '/': typeof IndexRoute 39 + '/cabinet': typeof CabinetRoute 39 40 '/login': typeof LoginRoute 40 - '/shared': typeof SharedRoute 41 41 } 42 42 export interface FileRoutesById { 43 43 __root__: typeof rootRouteImport 44 44 '/': typeof IndexRoute 45 + '/cabinet': typeof CabinetRoute 45 46 '/login': typeof LoginRoute 46 - '/shared': typeof SharedRoute 47 47 } 48 48 export interface FileRouteTypes { 49 49 fileRoutesByFullPath: FileRoutesByFullPath 50 - fullPaths: '/' | '/login' | '/shared' 50 + fullPaths: '/' | '/cabinet' | '/login' 51 51 fileRoutesByTo: FileRoutesByTo 52 - to: '/' | '/login' | '/shared' 53 - id: '__root__' | '/' | '/login' | '/shared' 52 + to: '/' | '/cabinet' | '/login' 53 + id: '__root__' | '/' | '/cabinet' | '/login' 54 54 fileRoutesById: FileRoutesById 55 55 } 56 56 export interface RootRouteChildren { 57 57 IndexRoute: typeof IndexRoute 58 + CabinetRoute: typeof CabinetRoute 58 59 LoginRoute: typeof LoginRoute 59 - SharedRoute: typeof SharedRoute 60 60 } 61 61 62 62 declare module '@tanstack/react-router' { 63 63 interface FileRoutesByPath { 64 - '/shared': { 65 - id: '/shared' 66 - path: '/shared' 67 - fullPath: '/shared' 68 - preLoaderRoute: typeof SharedRouteImport 69 - parentRoute: typeof rootRouteImport 70 - } 71 64 '/login': { 72 65 id: '/login' 73 66 path: '/login' 74 67 fullPath: '/login' 75 68 preLoaderRoute: typeof LoginRouteImport 69 + parentRoute: typeof rootRouteImport 70 + } 71 + '/cabinet': { 72 + id: '/cabinet' 73 + path: '/cabinet' 74 + fullPath: '/cabinet' 75 + preLoaderRoute: typeof CabinetRouteImport 76 76 parentRoute: typeof rootRouteImport 77 77 } 78 78 '/': { ··· 87 87 88 88 const rootRouteChildren: RootRouteChildren = { 89 89 IndexRoute: IndexRoute, 90 + CabinetRoute: CabinetRoute, 90 91 LoginRoute: LoginRoute, 91 - SharedRoute: SharedRoute, 92 92 } 93 93 export const routeTree = rootRouteImport 94 94 ._addFileChildren(rootRouteChildren)
+2 -43
web/src/routes/__root.tsx
··· 1 - import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; 2 - import { 3 - Files, 4 - ShareNetwork, 5 - SignIn, 6 - ShieldCheck, 7 - } from "@phosphor-icons/react"; 1 + import { createRootRoute, Outlet } from "@tanstack/react-router"; 8 2 9 3 function RootLayout() { 10 - return ( 11 - <div className="flex min-h-screen flex-col"> 12 - <nav className="flex items-center gap-6 border-b border-neutral-200 px-6 py-3"> 13 - <Link to="/" className="flex items-center gap-1.5 font-semibold"> 14 - <ShieldCheck size={20} weight="bold" /> 15 - Opake 16 - </Link> 17 - <div className="flex items-center gap-4"> 18 - <Link 19 - to="/" 20 - className="flex items-center gap-1 text-sm [&.active]:font-medium" 21 - > 22 - <Files size={16} /> 23 - Files 24 - </Link> 25 - <Link 26 - to="/shared" 27 - className="flex items-center gap-1 text-sm [&.active]:font-medium" 28 - > 29 - <ShareNetwork size={16} /> 30 - Shared 31 - </Link> 32 - <Link 33 - to="/login" 34 - className="flex items-center gap-1 text-sm [&.active]:font-medium" 35 - > 36 - <SignIn size={16} /> 37 - Login 38 - </Link> 39 - </div> 40 - </nav> 41 - <main className="flex-1 p-6"> 42 - <Outlet /> 43 - </main> 44 - </div> 45 - ); 4 + return <Outlet />; 46 5 } 47 6 48 7 export const Route = createRootRoute({
+78
web/src/routes/cabinet.tsx
··· 1 + import { useState } from "react"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { Sidebar } from "@/components/cabinet/Sidebar"; 4 + import { TopBar } from "@/components/cabinet/TopBar"; 5 + import { PanelStack } from "@/components/cabinet/PanelStack"; 6 + import type { FileItem, Panel, PanelType } from "@/components/cabinet/types"; 7 + 8 + function CabinetPage() { 9 + const [panels, setPanels] = useState<Panel[]>([ 10 + { id: "root", type: "root", title: "The Cabinet" }, 11 + ]); 12 + const [viewMode, setViewMode] = useState<"list" | "grid">("list"); 13 + const [searchQuery, setSearchQuery] = useState(""); 14 + const [starred, setStarred] = useState( 15 + new Set(["fi-strategy", "fi-brief", "f-projects", "sh-2"]), 16 + ); 17 + 18 + const currentPanel = panels[panels.length - 1]; 19 + 20 + const openSection = (type: PanelType, title: string) => { 21 + setPanels([{ id: type, type, title }]); 22 + }; 23 + 24 + const openItem = (item: FileItem) => { 25 + if (item.kind === "folder") { 26 + setPanels((prev) => [ 27 + ...prev, 28 + { id: item.id, type: "folder", title: item.name, data: item }, 29 + ]); 30 + } 31 + }; 32 + 33 + const goToPanel = (index: number) => { 34 + setPanels((prev) => prev.slice(0, index + 1)); 35 + }; 36 + 37 + const closePanel = () => { 38 + setPanels((prev) => prev.slice(0, -1)); 39 + }; 40 + 41 + const toggleStar = (id: string) => { 42 + setStarred((prev) => { 43 + const next = new Set(prev); 44 + next.has(id) ? next.delete(id) : next.add(id); 45 + return next; 46 + }); 47 + }; 48 + 49 + return ( 50 + <div className="flex h-screen overflow-hidden bg-base-300 font-sans"> 51 + <Sidebar 52 + activePanelType={currentPanel.type} 53 + panelDepth={panels.length} 54 + onOpenSection={openSection} 55 + /> 56 + <main className="flex flex-1 flex-col overflow-hidden"> 57 + <TopBar 58 + searchQuery={searchQuery} 59 + onSearchChange={setSearchQuery} 60 + onOpenSettings={() => openSection("settings", "Settings")} 61 + /> 62 + <PanelStack 63 + panels={panels} 64 + viewMode={viewMode} 65 + onViewModeChange={setViewMode} 66 + onOpenItem={openItem} 67 + onGoToPanel={goToPanel} 68 + onClosePanel={closePanel} 69 + onStar={toggleStar} 70 + /> 71 + </main> 72 + </div> 73 + ); 74 + } 75 + 76 + export const Route = createFileRoute("/cabinet")({ 77 + component: CabinetPage, 78 + });
+58 -6
web/src/routes/index.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 1 + import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { ArrowRight } from "@phosphor-icons/react"; 3 + import { OpakeLogo } from "@/components/OpakeLogo"; 2 4 3 - function FilesPage() { 5 + function LandingPage() { 4 6 return ( 5 - <div> 6 - <h1 className="text-xl font-semibold">Files</h1> 7 - <p className="mt-2 text-neutral-500">File browser goes here.</p> 7 + <div className="flex min-h-screen flex-col bg-base-300 font-sans"> 8 + {/* Nav */} 9 + <nav className="fixed inset-x-0 top-0 z-50 flex items-center justify-between border-b border-base-300/50 bg-base-300/85 px-10 py-3.5 backdrop-blur-[14px]"> 10 + <OpakeLogo /> 11 + <Link 12 + to="/cabinet" 13 + className="btn btn-neutral btn-sm gap-2 text-[13px]" 14 + > 15 + Open the Cabinet 16 + <ArrowRight size={14} /> 17 + </Link> 18 + </nav> 19 + 20 + {/* Hero */} 21 + <section className="flex min-h-screen flex-col items-center justify-center px-10 pt-[120px] pb-20"> 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" /> 29 + </div> 30 + 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"> 32 + Your data,{" "} 33 + <em className="text-primary">freely shared</em>, 34 + <br /> 35 + privately kept. 36 + </h1> 37 + 38 + <p className="mb-10 max-w-[520px] text-center text-[1.05rem] leading-[1.75] text-secondary"> 39 + Opake exists because privacy and collaboration should not be a 40 + tradeoff. Your files — encrypted, owned, shared on your terms — through 41 + decentralised identity, with no central authority in between. 42 + </p> 43 + 44 + <div className="flex items-center gap-3.5"> 45 + <Link 46 + to="/cabinet" 47 + className="btn btn-neutral gap-2.5 shadow-[0_4px_20px_oklch(0.155_0.035_70/0.18)]" 48 + > 49 + Open the Cabinet 50 + <ArrowRight size={15} /> 51 + </Link> 52 + <a 53 + href="#about" 54 + className="btn btn-outline border-border-accent text-secondary hover:bg-accent" 55 + > 56 + Learn more 57 + </a> 58 + </div> 59 + </section> 8 60 </div> 9 61 ); 10 62 } 11 63 12 64 export const Route = createFileRoute("/")({ 13 - component: FilesPage, 65 + component: LandingPage, 14 66 });
-14
web/src/routes/shared.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - 3 - function SharedPage() { 4 - return ( 5 - <div> 6 - <h1 className="text-xl font-semibold">Shared with me</h1> 7 - <p className="mt-2 text-neutral-500">Incoming shares go here.</p> 8 - </div> 9 - ); 10 - } 11 - 12 - export const Route = createFileRoute("/shared")({ 13 - component: SharedPage, 14 - });