this repo has no description
1
fork

Configure Feed

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

Rebuild web frontend: component architecture, docs system, SSR, CLS fixes

- Convert landing page from MDX to composed TSX components (eliminates
hydration mismatches from nested <p> tags that caused layout shifts)
- Add reusable content components: landing primitives, chapter/docs
layout, FAQ accordion, icon resolver, MDX provider
- Add CabinetMockup component for landing page hero
- Build docs system: MDX chapters with slug routing, docs registry,
platform toggle tabs, code blocks, callouts, sequence diagram stubs
- Add FAQ, troubleshooting, and all handbook content pages
- Add Bun SSR production server (serve.ts) for k8s deployment
- Fix CLS: font-display swap, metric-adjusted fallback @font-faces,
font preloads in SSR <head>, MDX blank-line fixes for list rendering
- Redesign public layout: nav with smooth-scroll hash links, grouped
footer with link columns
- Add dual-documentation drift warnings to README, CONTRIBUTING, and
architecture docs
- Streamline README for conciseness

+3019 -404
+4
.gitignore
··· 48 48 # .claude/settings.json — Claude Code project settings 49 49 # .claude/settings.local.json is per-developer, ignore separately if needed 50 50 # === End crosslink managed === 51 + 52 + # Claude Code 53 + .claude/settings.local.json 54 + .claude/hooks/node_modules/
+7
CONTRIBUTING.md
··· 1 + <!-- 2 + NOTE TO EDITORS: 3 + Opake uses a dual-documentation system. If you modify the technical details, 4 + architecture, or code style in this file, you MUST also update the 5 + corresponding MDX content in `web/src/content/` to prevent documentation drift. 6 + --> 7 + 1 8 # Contributing to Opake 2 9 3 10 Contributions welcome — from humans and AI agents alike.
+34 -197
README.md
··· 1 + <!-- 2 + NOTE TO EDITORS: 3 + Opake uses a dual-documentation system. If you modify the technical details, 4 + command list, or installation steps in this README, you MUST also update 5 + the corresponding MDX content in `web/src/content/` to prevent 6 + documentation drift. 7 + --> 8 + 1 9 # Opake 2 10 3 - **/oʊˈpɑːk/** — like "opaque," but Dutch-flavored. 11 + **/oʊˈpɑːk/** — like "opaque," but built for the AT Protocol. 4 12 5 - An encrypted personal cloud built on the [AT Protocol](https://atproto.com). 6 - 7 - Opake uses your existing PDS as a storage and identity layer. Files are encrypted client-side with AES-256-GCM before upload — the PDS only ever sees ciphertext. Custom lexicons under `app.opake.*` give structure to documents, encryption metadata, and sharing grants. 13 + An encrypted personal cloud where privacy and collaboration are no longer a tradeoff. Opake uses your PDS as a blind storage layer. Files are encrypted client-side (AES-256-GCM) before they ever touch the network. 8 14 9 15 Your data is opaque to everyone without the key. That's the point. 10 16 11 - [Issue Tracker](https://issues.opake.app) · [Architecture](docs/ARCHITECTURE.md) · [Lexicons](lexicons/README.md) 17 + [The Handbook](https://opake.app/docs) · [Issue Tracker](https://tangled.org/sans-self.org/opake.app/issues) · [Architecture](docs/ARCHITECTURE.md) 12 18 13 - ## Install 19 + ## Quick Start 14 20 21 + ### 1. Install 15 22 Requires Rust 1.75+. 16 - 17 23 ```sh 18 24 cargo install --path crates/opake-cli 19 25 ``` 20 26 21 - This puts `opake` in your `~/.cargo/bin/`. 22 - 23 - ## How It Works 24 - 25 - ``` 26 - plaintext file 27 - → encrypt with random AES-256-GCM key 28 - → upload ciphertext blob to PDS 29 - → wrap content key to owner's DID public key 30 - → store metadata as app.opake.document record 31 - ``` 32 - 33 - No modifications to the PDS. All crypto happens on your machine. 34 - 35 - ## Build From Source 36 - 37 - Requires Rust 1.75+. 38 - 27 + ### 2. Login 28 + Authenticates via OAuth (DPoP) and publishes your public encryption key. 39 29 ```sh 40 - git clone <repo-url> 41 - cd opake.dev 42 - cargo build --release 30 + opake login you.bsky.social 43 31 ``` 44 32 45 - Produces `target/release/opake` (CLI). The AppView is a separate Elixir/Phoenix app in `appview/`. 46 - 47 - ## Usage 48 - 33 + ### 3. Use 49 34 ```sh 50 - # authenticate (resolves PDS automatically, uses OAuth by default) 51 - opake login alice.example.com 52 - 53 - # explicit PDS override 54 - opake login alice.example.com --pds https://pds.example.com 55 - 56 - # force legacy password-based auth 57 - opake login alice.example.com --legacy 58 - 59 - # log in to a second account 60 - opake login bob.other.com 61 - 62 - # list accounts and switch default 63 - opake accounts 64 - opake set-default bob.other.com 65 - 66 - # upload a file (encrypts + uploads) 67 - opake upload photo.jpg --tags vacation,beach 68 - 69 - # upload into a directory 70 - opake upload photo.jpg --dir Photos 71 - 72 - # organize files into directories 73 - opake mkdir Photos 74 - opake tree 75 - 76 - # list your documents 77 - opake ls 35 + opake upload secret.pdf --tags confidential 36 + opake share secret.pdf bob.bsky.social 78 37 opake ls --long 79 - opake ls --tag vacation 80 - 81 - # use a specific account for any command 82 - opake ls --as alice.example.com 83 - opake upload doc.pdf --as did:plc:alice123 84 - 85 - # download and decrypt (your own files) 86 - opake download photo.jpg 87 - opake download photo.jpg -o ~/Downloads/copy.jpg 88 - 89 - # print a file to stdout (decrypt without saving) 90 - opake cat notes.txt 91 - opake cat Photos/notes.txt 92 - 93 - # download a shared file from another user (via grant URI) 94 - opake download --grant at://did:plc:abc/app.opake.grant/tid123 95 - 96 - # delete (supports paths and recursive directory deletion) 97 - opake rm photo.jpg 98 - opake rm Photos/photo.jpg 99 - opake rm -r Photos 100 - 101 - # move a file to another directory 102 - opake move photo.jpg Photos/ 103 - 104 - # view or edit document metadata 105 - opake metadata show photo.jpg 106 - opake metadata rename photo.jpg vacation-photo.jpg 107 - opake metadata tag add photo.jpg travel 108 - opake metadata tag remove photo.jpg travel 109 - opake metadata describe photo.jpg "Beach sunset from last summer" 110 - opake metadata describe photo.jpg --clear 111 - 112 - # resolve a handle or DID to see their public key 113 - opake resolve alice.example.com 114 - 115 - # share a file with another user 116 - opake share photo.jpg alice.example.com 117 - 118 - # list grants you've shared 119 - opake shared 120 - opake shared --long 121 - 122 - # revoke a share grant 123 - opake revoke at://did:plc:abc/app.opake.grant/tid123 124 - 125 - # check incoming grants (via AppView) 126 - opake inbox --appview https://appview.example.com 127 - opake inbox --long 128 - 129 - # keyring-based group sharing 130 - opake keyring create family-photos 131 - opake keyring ls 132 - opake keyring add-member family-photos alice.example.com 133 - opake upload photo.jpg --keyring family-photos 134 - opake download --keyring-member at://did:plc:abc/app.opake.document/tid456 135 - opake keyring remove-member family-photos alice.example.com 136 - 137 - # transfer encryption identity to a new device 138 - opake pair request # on the NEW device (polls for approval) 139 - opake pair approve # on the EXISTING device (select + approve) 140 - 141 - # delete all Opake data from PDS (see what would go) 142 - opake purge --dry-run 143 - 144 - # delete everything (prompts for confirmation phrase) 145 - opake purge 146 - 147 - # skip confirmation and also remove local identity 148 - opake purge --force 149 - 150 - # remove an account (defaults to only account if just one) 151 - opake logout 152 - opake logout bob.other.com 153 38 ``` 154 39 155 - Commands accept a filename, a path (`Photos/beach.jpg`), or an `at://` URI. If a filename matches multiple documents, you'll be prompted to use the full URI. 40 + ## How It Works 156 41 157 - The `--as` flag works with document commands (`upload`, `download`, `ls`, `rm`, `move`, `cat`, `tree`, `metadata`, `share`, `shared`, `revoke`) and accepts a handle or DID. 42 + 1. **Encrypt:** Plaintext → AES-256-GCM (random key K). 43 + 2. **Wrap:** Key K → X25519-HKDF-A256KW (wrapped to your DID). 44 + 3. **Publish:** Ciphertext blob + Metadata record → PDS. 158 45 159 - ## AppView 46 + No modifications to the PDS. All crypto happens on your machine. 160 47 161 - The AppView is an Elixir/Phoenix service (`appview/`) that indexes grants and keyrings from the AT Protocol firehose and serves them via a REST API. It enables grant discovery — "what's been shared with me?" — without scanning every PDS in the network. 48 + ## Repository Structure 162 49 163 - ```sh 164 - cd appview 165 - docker compose up -d # start postgres 166 - mix setup && mix phx.server # dev server on :6100 167 - docker compose --profile full up --build # production-like (postgres + appview) 168 - ``` 169 - 170 - See [docs/appview.md](docs/appview.md) for configuration, authentication, API endpoints, and deployment. 171 - 172 - ## Architecture 173 - 174 - Three Rust crates, an Elixir service, and a web frontend: 175 - 176 - - **`opake-core`** — platform-agnostic library (compiles to WASM). Encryption, records, XRPC client, document operations, `Storage` trait. 177 - - **`opake-cli`** — CLI binary. `FileStorage` (filesystem-backed), command dispatch. 178 - - **`opake-derive`** — Proc-macro crate. `RedactedDebug` derive macro for secret-safe Debug output. 179 - - **`appview/`** — Elixir/Phoenix indexer and REST API. Jetstream firehose consumer (WebSockex), PostgreSQL storage (Ecto), DID-scoped Ed25519 auth via Erlang `:crypto`. 180 - - **`web/`** — React SPA (Vite + TanStack Router + Tailwind/daisyUI). Uses `opake-core` via WASM. `IndexedDbStorage` (IndexedDB-backed) implements the same `Storage` trait as the CLI. 181 - 182 - See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the encryption model, crate structure, and design decisions. See [docs/FLOWS.md](docs/FLOWS.md) for sequence diagrams of every operation. 183 - 184 - ## Roadmap 185 - 186 - - [x] CLI foundation (auth, upload, download, ls, rm) 187 - - [x] Client-side AES-256-GCM encryption 188 - - [x] Asymmetric key wrapping (x25519-hkdf-a256kw) 189 - - [x] Automatic token refresh 190 - - [x] Multi-account support (--as flag, logout, set-default, accounts) 191 - - [x] Public key auto-publish on login (app.opake.publicKey record) 192 - - [x] DID resolution and public key extraction 193 - - [x] Direct file sharing between DIDs 194 - - [x] Cross-PDS shared file download (via --grant flag) 195 - - [x] Grant listing (shared command) 196 - - [x] AppView indexer (grants + keyrings from firehose) 197 - - [x] AppView REST API with DID-scoped Ed25519 auth 198 - - [x] Folder hierarchy (mkdir, tree, path-aware rm/mv/cat/upload) 199 - - [x] Grant discovery (inbox command — queries AppView) 200 - - [x] Keyring-based group sharing 201 - - [ ] Web UI — cabinet file browser (in progress, auth stubbed) 202 - - [x] AT Protocol OAuth (DPoP) for CLI and browser authentication 203 - - [x] Device-to-device identity pairing via PDS relay 204 - - [ ] Seed phrase key derivation for multi-device 50 + - `opake-core/` — Platform-agnostic library (Rust/WASM). 51 + - `opake-cli/` — CLI implementation. 52 + - `appview/` — Elixir/Phoenix indexer for grant discovery. 53 + - `web/` — React SPA (Vite + TanStack). 54 + - `lexicons/` — AT Protocol schemas (`app.opake.*`). 205 55 206 56 ## Development 207 57 208 58 ```sh 209 - cargo test # run all Rust tests 210 - cargo clippy # lint 211 - cargo fmt # format 212 - 213 - # web frontend 214 - cd web 215 - bun install # install deps 216 - bun run wasm:build # build opake-core WASM module 217 - bun run dev # start Vite dev server 218 - bun run test # run Vitest suite 59 + cargo test # Rust tests 60 + bun run wasm:build # Build WASM for web 61 + mix setup # Setup AppView 219 62 ``` 220 63 221 - CI runs on [Tangled](https://tangled.org) via `.tangled/workflows/test.yml`. 222 - 223 - See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. 224 - 225 - ## Lexicons 226 - 227 - Custom AT Protocol schemas live in `lexicons/`. See [lexicons/README.md](lexicons/README.md) for the full schema documentation and [lexicons/EXAMPLES.md](lexicons/EXAMPLES.md) for annotated example records. 64 + See [CONTRIBUTING.md](CONTRIBUTING.md) for the "mini-nuke" policy and commit conventions. 228 65 229 66 ## License 230 67
+6
appview/lib/opake_appview_web/router.ex
··· 2 2 @moduledoc """ 3 3 API router. Health is public; inbox and keyrings require Opake-Ed25519 auth. 4 4 All routes are rate-limited per IP. 5 + 6 + NOTE TO EDITORS: 7 + Opake uses a dual-documentation system. If you modify the API surface, 8 + authentication schemes, or indexing logic in this service, you MUST also 9 + update the corresponding MDX content in `web/src/content/` to prevent 10 + documentation drift. 5 11 """ 6 12 7 13 use OpakeAppviewWeb, :router
+7
docs/ARCHITECTURE.md
··· 1 + <!-- 2 + NOTE TO EDITORS: 3 + Opake uses a dual-documentation system. If you modify the architectural model, 4 + encryption schemes, or data flows in this file, you MUST also update the 5 + corresponding MDX content in `web/src/content/` to prevent documentation drift. 6 + --> 7 + 1 8 # Opake — Architecture 2 9 3 10 ## System Overview
+7
docs/FLOWS.md
··· 1 + <!-- 2 + NOTE TO EDITORS: 3 + Opake uses a dual-documentation system. If you modify the operation flows 4 + or data models in this file, you MUST also update the corresponding MDX 5 + content in `web/src/content/` to prevent documentation drift. 6 + --> 7 + 1 8 # Opake — Operation Flows 2 9 3 10 This document has been split into per-topic files for maintainability. See [flows/README.md](flows/README.md) for the index.
+7
docs/appview.md
··· 1 + <!-- 2 + NOTE TO EDITORS: 3 + Opake uses a dual-documentation system. If you modify the AppView service 4 + details or indexing logic in this file, you MUST also update the 5 + corresponding MDX content in `web/src/content/` to prevent documentation drift. 6 + --> 7 + 1 8 # AppView: API & Deployment 2 9 3 10 The AppView indexes `app.opake.grant` and `app.opake.keyring` records from the AT Protocol firehose and serves them via a REST API. It enables the `inbox` command — "what's been shared with me?" — without scanning every PDS in the network.
+7
lexicons/README.md
··· 1 + <!-- 2 + NOTE TO EDITORS: 3 + Opake uses a dual-documentation system. If you modify the AT Protocol 4 + schemas or lexicon definitions in this file, you MUST also update the 5 + corresponding MDX content in `web/src/content/` to prevent documentation drift. 6 + --> 7 + 1 8 # app.opake.* Lexicon Schemas 2 9 3 10 An encrypted personal cloud built on AT Protocol.
+3
web/.gitignore
··· 1 + # Claude Code 2 + .claude/settings.local.json 3 + .claude/hooks/node_modules/
+2 -1
web/package.json
··· 8 8 "wasm:build:dev": "wasm-pack build ../crates/opake-wasm --target web --dev --out-dir ../../web/src/wasm/opake-wasm --out-name opake", 9 9 "dev": "vite", 10 10 "build": "bun run wasm:build && tsc && vite build", 11 - "preview": "vite preview", 11 + "preview": "bun run serve.ts", 12 + "start": "bun run serve.ts", 12 13 "test": "vitest run", 13 14 "test:watch": "vitest", 14 15 "lint": "eslint src/",
+77
web/serve.ts
··· 1 + // Production SSR server for k8s deployment. 2 + // Serves static assets from dist/client/ and delegates everything else 3 + // to the TanStack Start SSR handler. 4 + 5 + import { readFileSync } from "node:fs"; 6 + import { join } from "node:path"; 7 + import { stat } from "node:fs/promises"; 8 + 9 + const PORT = Number(process.env.PORT ?? 3000); 10 + const DIST = new URL("./dist", import.meta.url).pathname; 11 + const CLIENT_DIR = join(DIST, "client"); 12 + 13 + const server = await import("./dist/server/server.js"); 14 + 15 + const MIME_TYPES: Record<string, string> = { 16 + ".html": "text/html", 17 + ".js": "application/javascript", 18 + ".css": "text/css", 19 + ".json": "application/json", 20 + ".woff2": "font/woff2", 21 + ".woff": "font/woff", 22 + ".ttf": "font/ttf", 23 + ".svg": "image/svg+xml", 24 + ".png": "image/png", 25 + ".jpg": "image/jpeg", 26 + ".ico": "image/x-icon", 27 + ".wasm": "application/wasm", 28 + ".map": "application/json", 29 + }; 30 + 31 + const IMMUTABLE_CACHE = "public, max-age=31536000, immutable"; 32 + const NO_CACHE = "public, max-age=0, must-revalidate"; 33 + 34 + function getMimeType(path: string): string { 35 + const ext = path.slice(path.lastIndexOf(".")); 36 + return MIME_TYPES[ext] ?? "application/octet-stream"; 37 + } 38 + 39 + async function tryStaticFile(pathname: string): Promise<Response | null> { 40 + const filePath = join(CLIENT_DIR, pathname); 41 + 42 + // Path traversal guard 43 + if (!filePath.startsWith(CLIENT_DIR)) return null; 44 + 45 + try { 46 + const info = await stat(filePath); 47 + if (!info.isFile()) return null; 48 + } catch { 49 + return null; 50 + } 51 + 52 + const body = readFileSync(filePath); 53 + const isHashed = pathname.startsWith("/assets/"); 54 + 55 + return new Response(body, { 56 + headers: { 57 + "content-type": getMimeType(filePath), 58 + "cache-control": isHashed ? IMMUTABLE_CACHE : NO_CACHE, 59 + }, 60 + }); 61 + } 62 + 63 + Bun.serve({ 64 + port: PORT, 65 + async fetch(request) { 66 + const url = new URL(request.url); 67 + 68 + // Try static assets first 69 + const staticResponse = await tryStaticFile(url.pathname); 70 + if (staticResponse) return staticResponse; 71 + 72 + // Delegate to TanStack Start SSR 73 + return server.default.fetch(request); 74 + }, 75 + }); 76 + 77 + console.log(`opake-web listening on :${PORT}`);
+290
web/src/components/CabinetMockup.tsx
··· 1 + import { 2 + FolderIcon, 3 + FileTextIcon, 4 + FileIcon, 5 + FileImageIcon, 6 + MagnifyingGlassIcon, 7 + BellIcon, 8 + LockIcon, 9 + UsersIcon, 10 + BookOpenIcon, 11 + GearIcon, 12 + ShieldCheckIcon, 13 + CaretRightIcon, 14 + } from "@phosphor-icons/react"; 15 + import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; 16 + import { OpakeLogo } from "./OpakeLogo"; 17 + 18 + // ─── Static data ────────────────────────────────────────────────────────────── 19 + 20 + interface NavEntry { 21 + readonly icon: PhosphorIcon; 22 + readonly label: string; 23 + readonly active?: boolean; 24 + } 25 + 26 + interface WorkspaceEntry { 27 + readonly name: string; 28 + readonly count: number; 29 + } 30 + 31 + interface MockFile { 32 + readonly name: string; 33 + readonly meta: string; 34 + readonly folder?: boolean; 35 + readonly iconBg: string; 36 + readonly iconText: string; 37 + readonly icon: PhosphorIcon; 38 + readonly badge?: { label: string; className: string }; 39 + } 40 + 41 + const MAIN_NAV: readonly NavEntry[] = [ 42 + { icon: FolderIcon, label: "Your Cabinet", active: true }, 43 + { icon: UsersIcon, label: "Sharing" }, 44 + ]; 45 + 46 + const BOTTOM_NAV: readonly NavEntry[] = [ 47 + { icon: BookOpenIcon, label: "Docs & Help" }, 48 + { icon: GearIcon, label: "Settings" }, 49 + ]; 50 + 51 + const WORKSPACES: readonly WorkspaceEntry[] = [ 52 + { name: "Family Photos", count: 23 }, 53 + { name: "Work Projects", count: 12 }, 54 + ]; 55 + 56 + const FILES: readonly MockFile[] = [ 57 + { 58 + name: "Documents", 59 + meta: "23 items", 60 + folder: true, 61 + iconBg: "bg-accent", 62 + iconText: "text-primary", 63 + icon: FolderIcon, 64 + badge: { label: "Private", className: "badge-accent text-primary border-border-accent" }, 65 + }, 66 + { 67 + name: "Projects", 68 + meta: "Shared · 7 items", 69 + folder: true, 70 + iconBg: "bg-accent", 71 + iconText: "text-primary", 72 + icon: FolderIcon, 73 + badge: { label: "Shared", className: "bg-bg-sage text-success border-success/30" }, 74 + }, 75 + { 76 + name: "Q4 Strategy.docx", 77 + meta: "245 KB · 2 days ago", 78 + iconBg: "bg-file-doc-bg", 79 + iconText: "text-file-doc", 80 + icon: FileTextIcon, 81 + badge: { label: "Private", className: "badge-accent text-primary border-border-accent" }, 82 + }, 83 + { 84 + name: "Budget 2026.xlsx", 85 + meta: "1.2 MB · Shared", 86 + iconBg: "bg-file-sheet-bg", 87 + iconText: "text-file-sheet", 88 + icon: FileIcon, 89 + badge: { label: "Shared", className: "bg-bg-sage text-success border-success/30" }, 90 + }, 91 + { 92 + name: "Design Brief.pdf", 93 + meta: "3.4 MB · Yesterday", 94 + iconBg: "bg-file-pdf-bg", 95 + iconText: "text-file-pdf", 96 + icon: FileTextIcon, 97 + badge: { label: "Private", className: "badge-accent text-primary border-border-accent" }, 98 + }, 99 + { 100 + name: "architecture.png", 101 + meta: "890 KB · 3 days ago", 102 + iconBg: "bg-file-image-bg", 103 + iconText: "text-file-image", 104 + icon: FileImageIcon, 105 + badge: { label: "Private", className: "badge-accent text-primary border-border-accent" }, 106 + }, 107 + ]; 108 + 109 + // ─── Subcomponents ──────────────────────────────────────────────────────────── 110 + 111 + function MockSidebar() { 112 + return ( 113 + <aside className="border-base-300/50 bg-base-200 flex w-48 shrink-0 flex-col border-r px-3 py-4"> 114 + <div className="mb-5 px-0.5"> 115 + <OpakeLogo /> 116 + </div> 117 + 118 + <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto"> 119 + {MAIN_NAV.map(({ icon: Icon, label, active }) => ( 120 + <div 121 + key={label} 122 + className={`text-ui flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75 ${ 123 + active ? "bg-accent text-primary" : "text-text-muted" 124 + }`} 125 + > 126 + <Icon size={14} weight={active ? "fill" : "regular"} /> 127 + <span className="flex-1">{label}</span> 128 + </div> 129 + ))} 130 + 131 + <div className="text-label text-text-faint mt-3.5 mb-1.5 ml-1 tracking-widest uppercase"> 132 + Workspaces 133 + </div> 134 + {WORKSPACES.map(({ name, count }) => ( 135 + <div 136 + key={name} 137 + className="text-ui text-text-muted flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75" 138 + > 139 + <div className="bg-accent text-micro text-primary flex size-5 shrink-0 items-center justify-center rounded-md font-semibold"> 140 + {name[0]} 141 + </div> 142 + <span className="flex-1">{name}</span> 143 + <span className="text-label text-text-faint">{count}</span> 144 + </div> 145 + ))} 146 + </nav> 147 + 148 + <div className="divider mx-1 my-0" /> 149 + <div className="flex flex-col gap-0.5"> 150 + {BOTTOM_NAV.map(({ icon: Icon, label }) => ( 151 + <div 152 + key={label} 153 + className="text-ui text-text-muted flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75" 154 + > 155 + <Icon size={14} /> 156 + <span className="flex-1">{label}</span> 157 + </div> 158 + ))} 159 + </div> 160 + </aside> 161 + ); 162 + } 163 + 164 + function MockTopBar() { 165 + return ( 166 + <header className="border-base-300/50 bg-base-300/90 flex shrink-0 items-center gap-3 border-b px-5 py-2.5 backdrop-blur-[10px]"> 167 + <div className="input input-bordered border-base-300/50 bg-base-100/80 text-ui flex max-w-72 flex-1 items-center gap-2 rounded-lg py-1.75"> 168 + <MagnifyingGlassIcon size={13} className="text-text-faint" /> 169 + <span className="text-text-faint text-ui">Search your cabinet…</span> 170 + </div> 171 + 172 + <div className="flex-1" /> 173 + 174 + <div className="btn btn-ghost btn-sm btn-square rounded-lg"> 175 + <BellIcon size={15} className="text-text-muted" /> 176 + </div> 177 + 178 + <div className="btn btn-ghost btn-sm gap-2 rounded-lg pl-1"> 179 + <div className="bg-accent text-caption text-primary flex size-7 items-center justify-center rounded-full font-semibold"> 180 + V 181 + </div> 182 + <span className="text-secondary text-xs font-normal">veerle.bsky.social</span> 183 + </div> 184 + </header> 185 + ); 186 + } 187 + 188 + function MockFileRow({ file }: Readonly<{ file: MockFile }>) { 189 + const Icon = file.icon; 190 + return ( 191 + <div className="hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors"> 192 + <div 193 + className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${file.iconBg} ${file.iconText}`} 194 + > 195 + <Icon size={15} weight={file.folder ? "fill" : "regular"} /> 196 + </div> 197 + 198 + <div className="min-w-0 flex-1"> 199 + <div className="text-ui text-base-content flex items-center truncate"> 200 + {file.name} 201 + {file.folder && ( 202 + <> 203 + &nbsp;&nbsp; 204 + <CaretRightIcon size={13} className="text-text-faint" /> 205 + </> 206 + )} 207 + </div> 208 + <div className="text-caption text-text-faint mt-0.5">{file.meta}</div> 209 + </div> 210 + 211 + {file.badge && ( 212 + <span 213 + className={`badge badge-sm text-label gap-1 border tracking-wide ${file.badge.className}`} 214 + > 215 + <LockIcon size={8} weight="bold" /> 216 + {file.badge.label} 217 + </span> 218 + )} 219 + </div> 220 + ); 221 + } 222 + 223 + function MockPanelShell() { 224 + return ( 225 + <div className="relative flex-1 overflow-hidden p-5.5 pl-7"> 226 + {/* Ghost panels */} 227 + <div className="border-primary/15 bg-bg-ghost-1 absolute inset-y-5.5 right-5.5 left-7 z-1 -translate-x-2.5 -translate-y-2.5 rounded-2xl border" /> 228 + <div className="border-base-300/50 bg-bg-ghost-2 shadow-panel-sm absolute inset-y-5.5 right-5.5 left-7 z-2 -translate-x-1.25 -translate-y-1.25 rounded-2xl border" /> 229 + 230 + {/* Active panel */} 231 + <div className="border-base-300/50 bg-base-100 shadow-panel-lg absolute inset-y-5.5 right-5.5 left-7 z-10 flex flex-col overflow-hidden rounded-2xl border"> 232 + {/* Header */} 233 + <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 234 + <div className="text-ui flex flex-1 items-center gap-1.5"> 235 + <span className="text-text-faint">Cabinet</span> 236 + <span className="text-text-faint text-label">›</span> 237 + <span className="text-base-content font-medium">Documents</span> 238 + </div> 239 + </div> 240 + 241 + {/* File list */} 242 + <div className="min-h-0 flex-1 overflow-y-auto p-3"> 243 + <div className="flex flex-col gap-px"> 244 + {FILES.map((file) => ( 245 + <MockFileRow key={file.name} file={file} /> 246 + ))} 247 + </div> 248 + </div> 249 + 250 + {/* Footer */} 251 + <div className="border-base-300/50 bg-base-100/60 flex shrink-0 items-center gap-2 border-t px-4 py-2.25"> 252 + <ShieldCheckIcon size={11} className="text-primary" /> 253 + <span className="text-caption text-text-faint">End-to-end encrypted</span> 254 + <div className="flex-1" /> 255 + <span className="font-display text-ui text-text-faint italic">3 panels deep</span> 256 + </div> 257 + </div> 258 + </div> 259 + ); 260 + } 261 + 262 + // ─── Exported mockup ────────────────────────────────────────────────────────── 263 + 264 + export function CabinetMockup() { 265 + return ( 266 + <div className="shadow-panel-lg overflow-hidden rounded-2xl border border-[rgba(112,83,40,0.13)]"> 267 + {/* Browser chrome */} 268 + <div className="bg-base-100 flex items-center gap-1.5 border-b border-[rgba(112,83,40,0.13)] px-4 py-2.5"> 269 + <div className="size-2.5 rounded-full bg-[#D9B8A0]" /> 270 + <div className="size-2.5 rounded-full bg-[#D4C4A8]" /> 271 + <div className="size-2.5 rounded-full bg-[#C8D4B8]" /> 272 + <div className="flex flex-1 justify-center"> 273 + <div className="bg-base-300 flex h-5.5 w-48 items-center gap-1.5 rounded-md border border-[rgba(112,83,40,0.13)] px-3"> 274 + <LockIcon size={9} className="text-text-faint" /> 275 + <span className="text-text-faint text-[10px]">opake.app/cabinet</span> 276 + </div> 277 + </div> 278 + </div> 279 + 280 + {/* App layout */} 281 + <div className="bg-base-300 flex h-96"> 282 + <MockSidebar /> 283 + <div className="flex flex-1 flex-col overflow-hidden"> 284 + <MockTopBar /> 285 + <MockPanelShell /> 286 + </div> 287 + </div> 288 + </div> 289 + ); 290 + }
+38
web/src/components/content/MdxProvider.tsx
··· 1 + import type { ComponentType } from "react"; 2 + import { Link } from "@tanstack/react-router"; 3 + import * as contentComponents from "./index"; 4 + 5 + /** 6 + * All custom components available to MDX files. 7 + * Standard HTML overrides (a → Link) are mixed in. 8 + */ 9 + const components: Readonly<Record<string, ComponentType<never>>> = { 10 + ...contentComponents, 11 + a: (({ href, children, ...rest }: { href?: string; children?: React.ReactNode }) => { 12 + if (href?.startsWith("/")) { 13 + return ( 14 + <Link to={href} {...rest}> 15 + {children} 16 + </Link> 17 + ); 18 + } 19 + return ( 20 + <a href={href} target="_blank" rel="noopener noreferrer" {...rest}> 21 + {children} 22 + </a> 23 + ); 24 + }) as ComponentType<never>, 25 + }; 26 + 27 + interface MdxContentProps { 28 + readonly Content: ComponentType<{ readonly components?: Record<string, ComponentType<never>> }>; 29 + readonly className?: string; 30 + } 31 + 32 + export function MdxContent({ Content, className }: MdxContentProps) { 33 + return ( 34 + <div className={className}> 35 + <Content components={components} /> 36 + </div> 37 + ); 38 + }
+184
web/src/components/content/chapter.tsx
··· 1 + import { 2 + type ReactNode, 3 + type ReactElement, 4 + useState, 5 + Children, 6 + isValidElement, 7 + createContext, 8 + useContext, 9 + } from "react"; 10 + import { InfoIcon, WarningIcon, DesktopIcon, TerminalIcon } from "@phosphor-icons/react"; 11 + import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; 12 + 13 + /* ─── Chapter header ───────────────────────────────────────────────────────── */ 14 + 15 + interface ChapterHeaderProps { 16 + readonly title: string; 17 + } 18 + 19 + export function ChapterHeader({ title }: ChapterHeaderProps) { 20 + return ( 21 + <div className="mb-8"> 22 + <h1 className="font-display text-base-content text-[clamp(1.8rem,4vw,2.8rem)] leading-[1.15] font-normal tracking-tight"> 23 + {title} 24 + </h1> 25 + </div> 26 + ); 27 + } 28 + 29 + /* ─── Lead paragraph ───────────────────────────────────────────────────────── */ 30 + 31 + interface ChildrenProps { 32 + readonly children: ReactNode; 33 + } 34 + 35 + export function Lead({ children }: ChildrenProps) { 36 + return <div className="text-secondary mb-8 text-[1.05rem] leading-[1.8]">{children}</div>; 37 + } 38 + 39 + /* ─── Callout ──────────────────────────────────────────────────────────────── */ 40 + 41 + const CALLOUT_STYLES = { 42 + info: { 43 + container: "border-info/30 bg-info/5", 44 + icon: InfoIcon, 45 + iconClass: "text-info", 46 + }, 47 + warning: { 48 + container: "border-warning/30 bg-warning/5", 49 + icon: WarningIcon, 50 + iconClass: "text-warning", 51 + }, 52 + } as const; 53 + 54 + interface CalloutProps { 55 + readonly type: "info" | "warning"; 56 + readonly children: ReactNode; 57 + } 58 + 59 + export function Callout({ type, children }: CalloutProps) { 60 + const style = CALLOUT_STYLES[type]; 61 + const IconComponent = style.icon; 62 + 63 + return ( 64 + <aside 65 + role="note" 66 + className={`my-6 flex items-center gap-3 rounded-xl border p-4 ${style.container}`} 67 + > 68 + <IconComponent size={18} weight="fill" className={`mt-0.5 shrink-0 ${style.iconClass}`} /> 69 + <div className="prose text-[0.88rem] leading-relaxed">{children}</div> 70 + </aside> 71 + ); 72 + } 73 + 74 + /* ─── Platform toggle (Web App / CLI tabs) ─────────────────────────────────── */ 75 + 76 + const PlatformContext = createContext<string>("Web App"); 77 + 78 + interface PlatformToggleProps { 79 + readonly children: ReactNode; 80 + } 81 + 82 + export function PlatformToggle({ children }: PlatformToggleProps) { 83 + const tabs = Children.toArray(children).filter( 84 + (child): child is ReactElement<PlatformTabProps> => 85 + isValidElement(child) && 86 + child.props != null && 87 + typeof child.props === "object" && 88 + "name" in child.props, 89 + ); 90 + 91 + const tabNames = tabs.map((tab) => tab.props.name); 92 + const [active, setActive] = useState(tabNames[0] ?? "Web App"); 93 + 94 + return ( 95 + <div className="border-border-accent/40 my-6 overflow-hidden rounded-xl border"> 96 + <div role="tablist" className="bg-base-200/60 flex border-b border-inherit"> 97 + {tabNames.map((name) => { 98 + const isActive = name === active; 99 + const Icon = name === "CLI" ? TerminalIcon : DesktopIcon; 100 + 101 + return ( 102 + <button 103 + key={name} 104 + role="tab" 105 + aria-selected={isActive} 106 + onClick={() => setActive(name)} 107 + className={`text-ui flex items-center gap-1.5 px-4 py-2.5 font-medium transition-colors ${ 108 + isActive 109 + ? "border-primary text-base-content border-b-2" 110 + : "text-text-muted hover:text-secondary" 111 + }`} 112 + > 113 + <Icon size={14} /> 114 + {name} 115 + </button> 116 + ); 117 + })} 118 + </div> 119 + <div className="bg-base-100 p-4"> 120 + <PlatformContext.Provider value={active}>{children}</PlatformContext.Provider> 121 + </div> 122 + </div> 123 + ); 124 + } 125 + 126 + interface PlatformTabProps { 127 + readonly name: string; 128 + readonly children: ReactNode; 129 + } 130 + 131 + export function PlatformTab({ name, children }: PlatformTabProps) { 132 + const active = useContext(PlatformContext); 133 + if (name !== active) return null; 134 + 135 + return <div className="prose text-[0.88rem] leading-relaxed">{children}</div>; 136 + } 137 + 138 + /* ─── Code block ───────────────────────────────────────────────────────────── */ 139 + 140 + interface CodeBlockProps { 141 + readonly language: string; 142 + readonly title?: string; 143 + readonly children: ReactNode; 144 + } 145 + 146 + export function CodeBlock({ language, title, children }: CodeBlockProps) { 147 + const code = 148 + typeof children === "string" 149 + ? children.trim() 150 + : Array.isArray(children) 151 + ? (children as string[]).join("") 152 + : ""; 153 + 154 + return ( 155 + <div className="border-border-accent/30 my-4 overflow-hidden rounded-lg border"> 156 + {title && ( 157 + <div className="bg-base-200/60 border-b border-inherit px-4 py-1.5"> 158 + <span className="text-text-muted font-mono text-[0.72rem]">{title}</span> 159 + </div> 160 + )} 161 + <SyntaxHighlighter 162 + language={language} 163 + useInlineStyles={false} 164 + className="bg-base-100! text-ui m-0! p-4! leading-relaxed" 165 + > 166 + {code} 167 + </SyntaxHighlighter> 168 + </div> 169 + ); 170 + } 171 + 172 + /* ─── Sequence diagram (placeholder) ───────────────────────────────────────── */ 173 + 174 + interface SequenceDiagramProps { 175 + readonly id: string; 176 + } 177 + 178 + export function SequenceDiagram({ id }: SequenceDiagramProps) { 179 + return ( 180 + <div className="border-border-accent/30 bg-base-200/30 my-6 flex min-h-32 items-center justify-center rounded-xl border border-dashed"> 181 + <span className="text-text-muted text-ui italic">Diagram: {id}</span> 182 + </div> 183 + ); 184 + }
+70
web/src/components/content/docs.tsx
··· 1 + import { createElement, type ReactNode } from "react"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { ArrowRightIcon } from "@phosphor-icons/react"; 4 + import { resolveIcon } from "./icons"; 5 + 6 + /* ─── Docs index header ────────────────────────────────────────────────────── */ 7 + 8 + interface ChildrenProps { 9 + readonly children: ReactNode; 10 + } 11 + 12 + export function DocsHeader({ children }: ChildrenProps) { 13 + return <div className="mx-auto mb-12 max-w-3xl text-center">{children}</div>; 14 + } 15 + 16 + export function DocsTitle({ children }: ChildrenProps) { 17 + return ( 18 + <h1 className="font-display text-base-content mb-3 text-[clamp(2rem,5vw,3.4rem)] leading-[1.1] font-normal tracking-tight"> 19 + {children} 20 + </h1> 21 + ); 22 + } 23 + 24 + export function DocsSubtitle({ children }: ChildrenProps) { 25 + return <p className="text-secondary text-[1.05rem] leading-relaxed">{children}</p>; 26 + } 27 + 28 + /* ─── Docs index grid ──────────────────────────────────────────────────────── */ 29 + 30 + export function DocsIndexGrid({ children }: ChildrenProps) { 31 + return ( 32 + <div className="mx-auto grid max-w-4xl grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> 33 + {children} 34 + </div> 35 + ); 36 + } 37 + 38 + interface DocsIndexCardProps { 39 + readonly href: string; 40 + readonly icon: string; 41 + readonly children: ReactNode; 42 + } 43 + 44 + export function DocsIndexCard({ href, icon, children }: DocsIndexCardProps) { 45 + return ( 46 + <Link 47 + to={href} 48 + className="card border-border-accent/40 bg-base-100 group hover:shadow-panel-sm rounded-xl border p-5 transition-shadow" 49 + > 50 + <div className="mb-3 flex items-center justify-between"> 51 + <div className="bg-accent flex size-8 items-center justify-center rounded-lg"> 52 + {createElement(resolveIcon(icon), { size: 16, className: "text-primary" })} 53 + </div> 54 + <ArrowRightIcon 55 + size={14} 56 + className="text-text-faint transition-transform group-hover:translate-x-0.5" 57 + /> 58 + </div> 59 + {children} 60 + </Link> 61 + ); 62 + } 63 + 64 + export function DocsIndexTitle({ children }: ChildrenProps) { 65 + return <h3 className="text-base-content mb-1 text-[0.9rem] font-medium">{children}</h3>; 66 + } 67 + 68 + export function DocsIndexBody({ children }: ChildrenProps) { 69 + return <p className="text-text-muted text-ui leading-relaxed">{children}</p>; 70 + }
+27
web/src/components/content/faq.tsx
··· 1 + import type { ReactNode } from "react"; 2 + 3 + interface FaqSectionProps { 4 + readonly children: ReactNode; 5 + } 6 + 7 + export function FaqSection({ children }: FaqSectionProps) { 8 + return <div className="mx-auto flex max-w-3xl flex-col gap-2">{children}</div>; 9 + } 10 + 11 + interface FaqItemProps { 12 + readonly question: string; 13 + readonly children: ReactNode; 14 + } 15 + 16 + export function FaqItem({ question, children }: FaqItemProps) { 17 + return ( 18 + <details className="collapse-arrow border-border-accent/40 bg-base-100 collapse rounded-xl border"> 19 + <summary className="collapse-title text-base-content min-h-0 px-6 py-4 text-[0.95rem] font-medium"> 20 + {question} 21 + </summary> 22 + <div className="collapse-content prose text-secondary px-6 pb-4 text-[0.9rem] leading-relaxed"> 23 + {children} 24 + </div> 25 + </details> 26 + ); 27 + }
+48
web/src/components/content/icons.ts
··· 1 + import type { Icon } from "@phosphor-icons/react"; 2 + import { 3 + LockIcon, 4 + GraphIcon, 5 + ShareNetworkIcon, 6 + GlobeIcon, 7 + SparkleIcon, 8 + BookOpenIcon, 9 + QuestionIcon, 10 + UsersThreeIcon, 11 + ArrowsLeftRightIcon, 12 + } from "@phosphor-icons/react"; 13 + import { TerminalIcon } from "@phosphor-icons/react/dist/ssr"; 14 + 15 + export type IconName = 16 + | "lock" 17 + | "network" 18 + | "share" 19 + | "globe" 20 + | "sparkles" 21 + | "book" 22 + | "question" 23 + | "group" 24 + | "pairing" 25 + | "terminal"; 26 + 27 + const ICON_MAP: Readonly<Record<IconName, Icon>> = { 28 + lock: LockIcon, 29 + network: GraphIcon, 30 + share: ShareNetworkIcon, 31 + globe: GlobeIcon, 32 + sparkles: SparkleIcon, 33 + book: BookOpenIcon, 34 + question: QuestionIcon, 35 + group: UsersThreeIcon, 36 + pairing: ArrowsLeftRightIcon, 37 + terminal: TerminalIcon, 38 + }; 39 + 40 + export function resolveIcon(name: string): Icon { 41 + const icon = ICON_MAP[name as IconName] as Icon | undefined; 42 + if (!icon) { 43 + throw new Error( 44 + `Unknown icon name: "${name}". Valid names: ${Object.keys(ICON_MAP).join(", ")}`, 45 + ); 46 + } 47 + return icon; 48 + }
+42
web/src/components/content/index.ts
··· 1 + export { 2 + HeroSection, 3 + HeroHeadline, 4 + Highlight, 5 + HeroSubtext, 6 + CtaGroup, 7 + PrimaryCta, 8 + SecondaryCta, 9 + Divider, 10 + SectionHeader, 11 + InfoGrid, 12 + InfoCard, 13 + StepGrid, 14 + StepCard, 15 + StepTitle, 16 + StepBody, 17 + CenterAction, 18 + TextLink, 19 + ArrowRightIcon, 20 + } from "./landing"; 21 + 22 + export { 23 + DocsHeader, 24 + DocsTitle, 25 + DocsSubtitle, 26 + DocsIndexGrid, 27 + DocsIndexCard, 28 + DocsIndexTitle, 29 + DocsIndexBody, 30 + } from "./docs"; 31 + 32 + export { 33 + ChapterHeader, 34 + Lead, 35 + Callout, 36 + PlatformToggle, 37 + PlatformTab, 38 + CodeBlock, 39 + SequenceDiagram, 40 + } from "./chapter"; 41 + 42 + export { FaqSection, FaqItem } from "./faq";
+263
web/src/components/content/landing.tsx
··· 1 + import { createElement, type ReactNode } from "react"; 2 + import { Link } from "@tanstack/react-router"; 3 + import { resolveIcon } from "./icons"; 4 + 5 + /* ─── Hero ─────────────────────────────────────────────────────────────────── */ 6 + 7 + interface ChildrenProps { 8 + readonly children: ReactNode; 9 + } 10 + 11 + export function HeroSection({ children }: ChildrenProps) { 12 + return ( 13 + <section className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-6 pt-28 pb-20 sm:px-10"> 14 + {/* Warm radial wash */} 15 + <div 16 + className="pointer-events-none absolute inset-0" 17 + style={{ 18 + background: 19 + "radial-gradient(ellipse 70% 55% at 50% -5%, rgba(160, 125, 60, 0.09) 0%, transparent 65%)", 20 + }} 21 + /> 22 + <div className="relative z-10 flex flex-col items-center">{children}</div> 23 + </section> 24 + ); 25 + } 26 + 27 + export function HeroHeadline({ children }: ChildrenProps) { 28 + return ( 29 + <h1 className="font-display text-base-content mb-7 max-w-208 text-center text-[clamp(2.6rem,7.5vw,6.2rem)] leading-[1.04] font-normal tracking-tight"> 30 + {children} 31 + </h1> 32 + ); 33 + } 34 + 35 + export function Highlight({ children }: ChildrenProps) { 36 + return <em className="text-primary not-italic">{children}</em>; 37 + } 38 + 39 + export function HeroSubtext({ children }: ChildrenProps) { 40 + return ( 41 + <div className="text-secondary mx-auto mb-10 max-w-lg text-center text-[1.05rem] leading-[1.75]"> 42 + {children} 43 + </div> 44 + ); 45 + } 46 + 47 + /* ─── CTAs ─────────────────────────────────────────────────────────────────── */ 48 + 49 + export function CtaGroup({ children }: ChildrenProps) { 50 + return <div className="flex items-center gap-3.5">{children}</div>; 51 + } 52 + 53 + interface CtaProps { 54 + readonly href: string; 55 + readonly children: ReactNode; 56 + } 57 + 58 + export function PrimaryCta({ href, children }: CtaProps) { 59 + return ( 60 + <Link 61 + to={href} 62 + className="btn btn-neutral gap-2.5 shadow-[0_4px_20px_oklch(0.155_0.035_70/0.18)]" 63 + > 64 + {children} 65 + </Link> 66 + ); 67 + } 68 + 69 + export function SecondaryCta({ href, children }: CtaProps) { 70 + return ( 71 + <Link to={href} className="btn btn-outline border-border-accent text-secondary hover:bg-accent"> 72 + {children} 73 + </Link> 74 + ); 75 + } 76 + 77 + /* ─── Divider ──────────────────────────────────────────────────────────────── */ 78 + 79 + interface DividerProps { 80 + readonly text: string; 81 + } 82 + 83 + export function Divider({ text }: DividerProps) { 84 + return ( 85 + <div 86 + id={text 87 + .toLowerCase() 88 + .replace(/[^a-z0-9]+/g, "-") 89 + // eslint-disable-next-line sonarjs/slow-regex -- non-user input 90 + .replace(/-+$/, "")} 91 + className="divider text-caption text-primary before:bg-border-accent after:bg-border-accent mx-auto mb-8 w-80 tracking-[0.18em] uppercase" 92 + > 93 + {text} 94 + </div> 95 + ); 96 + } 97 + 98 + /* ─── Section ──────────────────────────────────────────────────────────────── */ 99 + 100 + interface SectionProps { 101 + readonly id?: string; 102 + readonly children: ReactNode; 103 + readonly surface?: "default" | "raised"; 104 + } 105 + 106 + export function Section({ id, children, surface = "default" }: SectionProps) { 107 + const bg = surface === "raised" ? "bg-base-100" : ""; 108 + return ( 109 + <section id={id} className={`border-border-accent/30 border-t px-6 py-32 sm:px-10 ${bg}`}> 110 + <div className="mx-auto max-w-5xl">{children}</div> 111 + </section> 112 + ); 113 + } 114 + 115 + export function SectionHeader({ children }: ChildrenProps) { 116 + return ( 117 + <h2 className="font-display text-base-content mx-auto mb-12 max-w-3xl text-center text-[clamp(1.8rem,4vw,3rem)] leading-[1.15] font-normal tracking-tight"> 118 + {children} 119 + </h2> 120 + ); 121 + } 122 + 123 + /* ─── Info grid (2-col layout: prose left, cards right) ────────────────────── */ 124 + 125 + interface InfoGridProps { 126 + readonly description: ReactNode; 127 + readonly children: ReactNode; 128 + } 129 + 130 + export function InfoGrid({ description, children }: InfoGridProps) { 131 + return ( 132 + <div className="grid grid-cols-1 items-center gap-20 lg:grid-cols-2"> 133 + <div>{description}</div> 134 + <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">{children}</div> 135 + </div> 136 + ); 137 + } 138 + 139 + interface InfoCardProps { 140 + readonly icon: string; 141 + readonly title: string; 142 + readonly children: ReactNode; 143 + } 144 + 145 + export function InfoCard({ icon, title, children }: InfoCardProps) { 146 + return ( 147 + <div className="card border-border-accent/40 bg-base-100 shadow-panel-sm rounded-xl border p-5"> 148 + <div className="bg-accent mb-3 flex size-8.5 items-center justify-center rounded-[9px]"> 149 + {createElement(resolveIcon(icon), { size: 16, className: "text-primary" })} 150 + </div> 151 + <h3 className="text-base-content mb-1.5 text-[0.85rem] font-medium">{title}</h3> 152 + <p className="text-text-muted text-[0.75rem] leading-[1.6]">{children}</p> 153 + </div> 154 + ); 155 + } 156 + 157 + /* ─── Step cards (numbered row) ────────────────────────────────────────────── */ 158 + 159 + export function StepGrid({ children }: ChildrenProps) { 160 + return <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">{children}</div>; 161 + } 162 + 163 + interface StepCardProps { 164 + readonly num: string; 165 + readonly icon: string; 166 + readonly featured?: boolean; 167 + readonly children: ReactNode; 168 + } 169 + 170 + export function StepCard({ num, icon, featured, children }: StepCardProps) { 171 + return ( 172 + <div className="card border-border-accent/40 bg-base-300 rounded-xl border p-6"> 173 + <div className="mb-4 flex items-center justify-between"> 174 + <span className="font-display text-text-faint text-sm tracking-wide uppercase italic"> 175 + {num} 176 + </span> 177 + <div 178 + className={`flex size-10 items-center justify-center rounded-xl ${featured ? "bg-base-content" : "bg-accent"}`} 179 + > 180 + {createElement(resolveIcon(icon), { 181 + size: 18, 182 + className: featured ? "text-base-100" : "text-primary", 183 + })} 184 + </div> 185 + </div> 186 + {children} 187 + </div> 188 + ); 189 + } 190 + 191 + export function StepTitle({ children }: ChildrenProps) { 192 + return <h3 className="text-base-content mb-1.5 text-[0.9rem] font-medium">{children}</h3>; 193 + } 194 + 195 + export function StepBody({ children }: ChildrenProps) { 196 + return <p className="text-text-muted text-ui leading-relaxed">{children}</p>; 197 + } 198 + 199 + /* ─── CTA block ────────────────────────────────────────────────────────────── */ 200 + 201 + interface CenterActionProps { 202 + readonly headline: ReactNode; 203 + readonly subtext: ReactNode; 204 + readonly children: ReactNode; 205 + } 206 + 207 + export function CenterAction({ headline, subtext, children }: CenterActionProps) { 208 + return ( 209 + <section className="px-6 py-32 sm:px-10"> 210 + <div className="bg-neutral text-neutral-content relative mx-auto max-w-195 overflow-hidden rounded-2xl px-10 py-18 text-center sm:px-16"> 211 + {/* Warm inner glow */} 212 + <div 213 + className="pointer-events-none absolute inset-0" 214 + style={{ 215 + background: 216 + "radial-gradient(ellipse at 30% 0%, rgba(154, 120, 64, 0.18) 0%, transparent 55%)", 217 + }} 218 + /> 219 + {/* Ornament */} 220 + <div className="font-display text-neutral-content relative z-10 mb-6 text-[28px] tracking-wide"> 221 + — ☙ ⚷ ❧ — 222 + </div> 223 + <div className="relative z-10 flex flex-col items-center"> 224 + <h2 className="font-display mb-4 text-[clamp(2rem,4vw,2.9rem)] leading-[1.18] font-normal"> 225 + {headline} 226 + </h2> 227 + <p className="text-neutral-content/80 mx-auto mb-10 max-w-sm text-base leading-[1.75]"> 228 + {subtext} 229 + </p> 230 + {children} 231 + </div> 232 + </div> 233 + </section> 234 + ); 235 + } 236 + 237 + interface TextLinkProps { 238 + readonly href: string; 239 + readonly children: ReactNode; 240 + } 241 + 242 + export function TextLink({ href, children }: TextLinkProps) { 243 + const isExternal = href.startsWith("http"); 244 + const className = 245 + "inline-flex items-center gap-1.5 text-[0.95rem] font-medium text-primary hover:text-primary/80"; 246 + 247 + if (isExternal) { 248 + return ( 249 + <a href={href} target="_blank" rel="noopener noreferrer" className={className}> 250 + {children} 251 + </a> 252 + ); 253 + } 254 + 255 + return ( 256 + <Link to={href} className={className}> 257 + {children} 258 + </Link> 259 + ); 260 + } 261 + 262 + /* Re-export ArrowRightIcon so MDX can reference it without an import */ 263 + export { ArrowRightIcon } from "@phosphor-icons/react";
+98
web/src/content/docs/at-protocol.mdx
··· 1 + <ChapterHeader title="The Open Network: Your Data, Anywhere" /> 2 + 3 + <Lead> 4 + Opake isn't a walled garden; it's a resident of the AT Protocol—a growing network of independent 5 + services. This means your data isn't trapped in a silo; it lives on a foundation that you control. 6 + </Lead> 7 + 8 + ## The Context: Why We Built This 9 + 10 + The "Social Web" as we knew it is broken. For years, we lived in digital fiefdoms where a single 11 + company could decide the fate of our data, our identities, and our connections. Elon Musk decided to buy Twitter 12 + and use it to destroy online communities and spread an agenda of hat. 13 + 14 + The urgency for a decentralized alternative became undeniable when major platforms began 15 + dismantling safety and moderation systems in favor of extremist ideologies. The goal of the 16 + AT Protocol is simple: to ensure that no single person or corporation can ever "own" your 17 + digital life again. 18 + 19 + ### Breaking the Silo 20 + 21 + In the traditional cloud (Google Drive, Dropbox, iCloud), your identity and your storage are 22 + permanently fused together. If you want to leave, you have to pack your bags, download 23 + everything, and "re-upload" it somewhere else. 24 + 25 + Opake uses the **AT Protocol** to break this cycle. 26 + 27 + ### 1. Your Digital Home (The PDS) 28 + 29 + When you use Opake, your files are stored on your **Personal Data Server (PDS)**. Think of 30 + this as your private home on the internet. 31 + 32 + - You can host it yourself on your own hardware. 33 + - You can use a trusted provider (like Bluesky). 34 + - You can move your entire home to a new host without losing a single file. 35 + 36 + ### 2. Take Your Data With You 37 + 38 + Because Opake speaks a universal language, your data is **portable by design**. You aren't 39 + locked into our interface. If someone builds a better file explorer tomorrow, you can simply 40 + log in with your handle and your files will be right there. Our code and algorithms are 41 + public; the "vault" belongs to you, we just provide the keys. 42 + 43 + <Callout type="info"> 44 + **Our Guarantee:** We don't want to trap you. We want to be the best way for you to manage your 45 + private cabinet, but we believe you should always have the right to leave and take your data with 46 + you. 47 + </Callout> 48 + 49 + --- 50 + 51 + ## Effortless Sharing Across the Network 52 + 53 + The real advantage of an open network is how easily you can connect. In traditional encrypted 54 + apps, you have to exchange complex "invite codes" or "public keys" just to see a single file. 55 + 56 + Since everyone on the AT Protocol already has a digital identity, sharing is built-in. 57 + 58 + ### No More Invite Codes 59 + 60 + If your friends or colleagues are on Bluesky, they already have a handle. You don't need to 61 + ask for their "Opake address." You just type their handle (`@name.bsky.social`), and Opake 62 + handles the rest. 63 + 64 + 1. Opake finds their unique digital ID. 65 + 2. It identifies their public lock on the network. 66 + 3. It creates a secure "Grant" that only their key can open. 67 + 68 + ### Collaboration Without Borders 69 + 70 + It doesn't matter if you are with one provider and your friend is with another. The servers 71 + talk to each other seamlessly. You can share a 1GB encrypted video with a friend on a 72 + different host, and they can stream it directly from your cabinet—safely, privately, 73 + and without any "middleman" watching from the sidelines. 74 + 75 + --- 76 + 77 + ## Resources & Further Reading 78 + 79 + Opake is part of a much larger ecosystem. To dive deeper into the philosophy and the 80 + tech behind this movement, we recommend these resources: 81 + 82 + ### Official Documentation 83 + 84 + - **[AT Protocol Glossary](https://atproto.com/guides/glossary):** A clear list of terms like PDS and Relays explained by the creators. 85 + - **[atproto.com](https://atproto.com):** The full technical blueprint for the network. 86 + 87 + ### Community Perspectives 88 + 89 + - **[A Rough Sketch of PDS Self-Hosting](https://mutualaid.info/posts/a-rough-sketch-of-at-protocol-and-pds-self-hosting/):** A brilliant, human-readable guide to owning your own bits. 90 + - **[Introduction to AT Protocol](https://mackuba.eu/2024/02/20/atproto-intro/):** A great "from scratch" deep dive into how the network functions. 91 + - **[A Social Filesystem](https://overreacted.io/a-social-filesystem/):** Dan Abramov's vision of the AT Protocol as a decentralized filesystem—the very vision Opake is building. 92 + 93 + <Callout type="info"> 94 + **The Network is the App:** Opake is just a lens. The AT Protocol is the foundation. This means 95 + your digital life is no longer at the mercy of a single company's terms of service. 96 + </Callout> 97 + 98 + Ready to learn about how we keep this open network private? Read about [Encryption & Keys](/docs/encryption-keys).
+177
web/src/content/docs/cli.mdx
··· 1 + <ChapterHeader title="The CLI Manual" /> 2 + 3 + <Lead> 4 + For the power users, the keyboard-bound, and the automation-obsessed. The Opake CLI is the 5 + reference implementation of our protocol. 6 + </Lead> 7 + 8 + ## Installation 9 + 10 + The CLI is built in Rust and distributed via `cargo`. Ensure you have Rust 1.75+ installed. 11 + 12 + <CodeBlock language="sh">cargo install --path crates/opake-cli</CodeBlock> 13 + 14 + This puts the `opake` binary in your `~/.cargo/bin/` directory. 15 + 16 + --- 17 + 18 + ## 1. Identity & Session Management 19 + 20 + Everything starts with a session. Opake supports multiple accounts and uses AT Protocol OAuth by default. 21 + 22 + ### Login 23 + 24 + <CodeBlock language="sh"> 25 + # Standard OAuth login 26 + opake login you.bsky.social 27 + 28 + # Explicit PDS override 29 + 30 + opake login you.bsky.social --pds https://pds.example.com 31 + 32 + # Legacy app-password fallback 33 + 34 + opake login you.bsky.social --legacy 35 + 36 + </CodeBlock> 37 + 38 + ### Managing Accounts 39 + 40 + <CodeBlock language="sh"> 41 + # List all authenticated accounts 42 + opake accounts 43 + 44 + # Set the default identity for future commands 45 + 46 + opake set-default bob.other.com 47 + 48 + # Use a specific account for a single command 49 + 50 + opake ls --as alice.example.com 51 + 52 + </CodeBlock> 53 + 54 + --- 55 + 56 + ## 2. The Social Filesystem 57 + 58 + Managing your encrypted vault from the terminal. 59 + 60 + ### Upload & Download 61 + 62 + <CodeBlock language="sh"> 63 + # Encrypt and upload a file 64 + opake upload photo.jpg --tags vacation,beach 65 + 66 + # Download and decrypt to a specific location 67 + 68 + opake download photo.jpg -o ~/Downloads/copy.jpg 69 + 70 + # Decrypt and stream to stdout (no local save) 71 + 72 + opake cat notes.txt 73 + 74 + </CodeBlock> 75 + 76 + ### Organization 77 + 78 + <CodeBlock language="sh"> 79 + # List files and directories 80 + opake ls --long 81 + 82 + # Create a virtual directory 83 + 84 + opake mkdir Photos 85 + 86 + # Visualize your vault hierarchy 87 + 88 + opake tree 89 + 90 + </CodeBlock> 91 + 92 + --- 93 + 94 + ## 3. Sharing & Collaboration 95 + 96 + Cryptographic access control at your fingertips. 97 + 98 + ### Direct Sharing (Grants) 99 + 100 + <CodeBlock language="sh"> 101 + # Share a file with another DID or handle 102 + opake share secret.pdf bob.bsky.social 103 + 104 + # List all grants you have issued 105 + 106 + opake shared 107 + 108 + # Check your inbox for incoming shares 109 + 110 + opake inbox 111 + 112 + # Revoke a grant by its URI 113 + 114 + opake revoke at://did:plc:123/app.opake.grant/tid456 115 + 116 + </CodeBlock> 117 + 118 + ### Group Sharing (Keyrings) 119 + 120 + <CodeBlock language="sh"> 121 + # Create a new group keyring 122 + opake keyring create "The Collective" 123 + 124 + # Add a member to the group 125 + 126 + opake keyring add-member "The Collective" alice.bsky.social 127 + 128 + # Upload a file to the group vault 129 + 130 + opake upload internal-docs.zip --keyring "The Collective" 131 + 132 + </CodeBlock> 133 + 134 + --- 135 + 136 + ## 4. Maintenance & Security 137 + 138 + ### Device Pairing 139 + 140 + <CodeBlock language="sh"> 141 + # Request identity transfer (on the NEW device) 142 + opake pair request 143 + 144 + # Approve identity transfer (on the EXISTING device) 145 + 146 + opake pair approve 147 + 148 + </CodeBlock> 149 + 150 + ### The Nuclear Option 151 + 152 + <CodeBlock language="sh"> 153 + # Logout of an account (removes local keys) 154 + opake logout alice.bsky.social 155 + 156 + # THE MINI NUKE: Delete every file in your vault, but keep your identity/keys 157 + 158 + opake rm -r / 159 + 160 + # THE FULL PURGE: Delete ALL Opake records from your PDS 161 + 162 + opake purge --dry-run 163 + opake purge --force 164 + 165 + </CodeBlock> 166 + 167 + <Callout type="warning"> 168 + **The Mini Nuke:** Running `opake rm -r /` is the fastest way to clear your entire vault while 169 + keeping your cryptographic identity intact. Use it when you want a fresh start without needing to 170 + re-pair your devices. 171 + </Callout> 172 + 173 + <Callout type="info"> 174 + **Pro-Tip:** Most commands support the `--help` flag for detailed usage and sub-command options. 175 + </Callout> 176 + 177 + Ready to learn about how these commands translate to the protocol? Check the [Technical Spec](/docs/protocol).
+66
web/src/content/docs/encryption-keys.mdx
··· 1 + <ChapterHeader title="Encryption & Keys" /> 2 + 3 + <Lead> 4 + Opake employs a hybrid encryption model: symmetric content encryption for speed, and asymmetric 5 + key wrapping for sharing and identity. 6 + </Lead> 7 + 8 + ## The Cryptographic Reality 9 + 10 + We don't use "bank-level security" buzzwords. We use standard, audited cryptographic primitives. If you're a developer, here is exactly what happens when you upload a file. 11 + 12 + ### 1. Content Encryption 13 + 14 + When you select a file to upload, your device generates a random 256-bit symmetric key (`Content Key`). 15 + 16 + The file is then encrypted using **AES-256-GCM**. This algorithm is fast and handles arbitrary-size data efficiently. This produces the ciphertext blob that will actually be uploaded to your PDS. 17 + 18 + <CodeBlock language="rust" title="opake-core/src/crypto/content.rs"> 19 + {`// The core primitive. 20 + pub fn encrypt_blob( 21 + plaintext: &[u8], 22 + key: &ContentKey 23 + ) -> Result<Vec<u8>, CryptoError> { 24 + // Generates 12-byte random nonce 25 + // Applies AES-GCM 26 + // Returns ciphertext with appended authentication tag 27 + }`} 28 + </CodeBlock> 29 + 30 + ### 2. Key Wrapping (The Lockbox) 31 + 32 + We have the encrypted file, but how do we store the `Content Key` so that _only you_ can decrypt it later? We wrap it. 33 + 34 + Your Opake identity consists of an **X25519** keypair. We take the `Content Key` and wrap it to your X25519 public key using a scheme called `x25519-hkdf-a256kw`. 35 + 36 + 1. We generate an ephemeral X25519 keypair. 37 + 2. We perform an ECDH key agreement with your public key. 38 + 3. We derive a wrapping key using HKDF-SHA256. 39 + 4. We wrap the `Content Key` using AES-256-KW. 40 + 41 + The result is a `WrappedKey`. This lockbox is safe to store publicly while being streamed through the AT Protocol firehose because it can only be opened by your private X25519 key. 42 + 43 + <Callout type="info"> 44 + **A Note on JWE:** We intentionally avoided the JSON Web Encryption (JWE) standard. Our specific 45 + HKDF info string (`opake-v1-x25519-hkdf-a256kw-{did}`) provides strict domain separation, 46 + preventing replay attacks across different users or protocol versions. 47 + </Callout> 48 + 49 + ## Cryptographic Heritage 50 + 51 + Opake doesn't reinvent the wheel. We stand on the shoulders of giants who have spent decades perfecting modern, easy-to-use, and highly secure encryption. Our design is heavily influenced by the same primitives used by the **Signal Protocol**. 52 + 53 + - **Signal's X25519 & Ed25519:** Just like Signal, we use Curve25519 (X25519) for our identity and key wrapping. It is the modern gold standard for elliptic curve cryptography—fast, secure, and designed to be resistant to many types of side-channel attacks. 54 + - **[Age (Actually Good Encryption)](https://github.com/FiloSottile/age):** Our file-based encryption model (encrypting for specific public key recipients) is conceptually very similar to the `age` tool. We believe in "static" encryption that is robust and doesn't rely on complex state-machine handshakes. 55 + - **[The Noise Protocol Framework](http://noiseprotocol.org/):** While we aren't a messaging protocol, we follow the Noise philosophy: using simple, composable primitives (Diffie-Hellman + HKDF + AEAD) to build secure systems. 56 + 57 + --- 58 + 59 + ## Where Are My Keys? 60 + 61 + Your private keys never touch the network. 62 + 63 + - **On the Web:** They are stored in your browser's `IndexedDB`, accessible only to the Opake web app domain. 64 + - **On the CLI:** They are stored in `~/.config/opake/accounts/`, guarded by strict `0600` UNIX file permissions. 65 + 66 + To learn how to transfer these keys securely to a new device without exposing them, read about [Multi-Device Survival](/docs/pairing).
+62
web/src/content/docs/getting-started.mdx
··· 1 + <ChapterHeader title="Getting Started" /> 2 + 3 + <Lead> 4 + Though we've designed Opake to be as intuitive as possible, these documents are for if you want to 5 + go a little more in-depth about our technology. Feel free to just get started, or to read on. 6 + </Lead> 7 + 8 + <Lead> 9 + Opake is a radical departure from traditional cloud storage. You are not creating an account on 10 + our servers; you are bringing your own identity and storage to our interface. 11 + </Lead> 12 + 13 + ## The Burden of Ownership 14 + 15 + Before we begin, an uncomfortable truth: **owning your data means owning your keys**. 16 + 17 + In traditional systems, if you lose your password, a helpful corporation resets it for you. They can do this because they ultimately hold the master keys to your kingdom. Opake cannot do this. We do not have your keys. Your Personal Data Server (PDS) does not have your keys. 18 + 19 + If you lose your keypair, your encrypted files remain mathematically opaque forever. There is no recovery. This is not a flaw; it is the fundamental guarantee of end-to-end encryption. 20 + 21 + <Callout type="warning"> 22 + Treat your Opake identity like a physical key to a very heavy vault. 23 + </Callout> 24 + 25 + --- 26 + 27 + ## 1. Entering your cabinet 28 + 29 + Whether you use the Web App or the CLI, the process begins by authenticating with your AT Protocol handle (e.g., `alice.bsky.social`). 30 + 31 + <PlatformToggle> 32 + <PlatformTab name="Web App"> 33 + 34 + 1. Navigate to the login screen. 35 + 2. Enter your handle (e.g., `you.bsky.social`). 36 + 3. You will be redirected to your PDS (such as Bluesky) to authorize Opake. 37 + 4. Once authorized, Opake generates your encryption identity entirely within your browser's local storage. 38 + 39 + </PlatformTab> 40 + <PlatformTab name="CLI"> 41 + Run the following command in your terminal: 42 + <CodeBlock language="sh">opake login you.bsky.social</CodeBlock> 43 + This will open a browser window for OAuth authorization. Your keys will be generated and stored 44 + securely in `~/.config/opake`. 45 + </PlatformTab> 46 + </PlatformToggle> 47 + 48 + ## 2. Keeping private what's in your drawers 49 + 50 + Inside your cabinet, everything looks familiar: files, folders, and organized grids. But this is a structure built entirely on your device. 51 + 52 + On the network, your data is hidden in plain sight. Because Opake encrypts your file names, tags, and folder paths before they ever leave your hand, the storage provider sees only a collection of nameless, scrambled records. They can see that something exists, but they have no way of knowing it’s a "Project" folder or a "Financial" PDF. 53 + 54 + Your cabinet only "assembles" itself once you provide your keys. Opake pulls these scrambled pieces from the network and organizes them back into the familiar hierarchy you expect—instantly and only for you.downloads these encrypted records, decrypts the metadata locally, and reconstructs the folder hierarchy on the fly. 55 + 56 + <Callout type="info"> 57 + **Why this matters:** Even if your server is compromised by a malicious admin, they will only see 58 + a flat list of opaque data and encrypted records. They cannot even tell if a file is a PDF or an 59 + image, nor what folder you've stored it in. 60 + </Callout> 61 + 62 + Ready to dive deeper into how the math actually works? Read about [Encryption & Keys](/docs/encryption-keys).
+59
web/src/content/docs/glossary.mdx
··· 1 + <ChapterHeader title="Glossary of Terms" /> 2 + 3 + <Lead>A quick-hit reference for the terminology we use across the Opake ecosystem.</Lead> 4 + 5 + ### AT Protocol (atproto) 6 + 7 + The decentralized social networking protocol that Opake is built on. It handles identity, data storage, and the communication between different servers. 8 + 9 + ### AES-256-GCM 10 + 11 + The symmetric encryption algorithm we use for file contents. It's fast, secure, and industry-standard. 12 + 13 + ### AppView 14 + 15 + A specialized service that indexes the [Firehose](#firehose) and provides a fast way to discover which files have been shared with you. It never sees your plaintext data. 16 + 17 + ### Cabinet 18 + 19 + Our name for the Opake interface—the place where you manage your files, keys, and sharing grants. 20 + 21 + ### Content Key 22 + 23 + A random 256-bit key generated for every single file. It's the key that actually "locks" the file content. 24 + 25 + ### DID (Decentralized Identifier) 26 + 27 + Your permanent, cryptographic ID on the AT Protocol (e.g., `did:plc:123...`). Unlike a handle, a DID never changes. 28 + 29 + ### Firehose 30 + 31 + The real-time stream of all public records being created on the AT Protocol. Our AppView "listens" to the firehose to find new Sharing Grants. 32 + 33 + ### Grant 34 + 35 + A record that "grants" a specific person access to a file by wrapping the file's [Content Key](#content-key) for their [Public Key](#public-key). 36 + 37 + ### Keyring 38 + 39 + A named group of users that shares a common Group Key. It makes sharing folders with teams or families effortless. 40 + 41 + ### PDS (Personal Data Server) 42 + 43 + The server where your data actually lives. You can host your own, or use a provider like Bluesky. 44 + 45 + ### Public Key 46 + 47 + The part of your cryptographic identity that you share with the world. Others use your public key to "wrap" files specifically for you. 48 + 49 + ### Wrapped Key 50 + 51 + A [Content Key](#content-key) that has been encrypted for a specific recipient using their [Public Key](#public-key). It's like a small lockbox that only one person can open. 52 + 53 + ### X25519 54 + 55 + The specific elliptic curve we use for asymmetric encryption and key wrapping. It's modern, fast, and secure. 56 + 57 + --- 58 + 59 + Still curious? Head back to the [Handbook Index](/docs).
+65
web/src/content/docs/index.mdx
··· 1 + <DocsHeader> 2 + <DocsTitle>The Opaque Handbook</DocsTitle> 3 + <DocsSubtitle>Everything you need to get the most out of Opake.</DocsSubtitle> 4 + </DocsHeader> 5 + 6 + <DocsIndexGrid> 7 + <DocsIndexCard href="/docs/getting-started" icon="sparkles"> 8 + <DocsIndexTitle>Getting Started</DocsIndexTitle> 9 + <DocsIndexBody>Set up your cabinet, create your first encrypted file, and explore the interface.</DocsIndexBody> 10 + </DocsIndexCard> 11 + 12 + <DocsIndexCard href="/docs/at-protocol" icon="network"> 13 + <DocsIndexTitle>AT Protocol</DocsIndexTitle> 14 + <DocsIndexBody> 15 + The open standard powering Opake — identity, data portability, and federation. 16 + </DocsIndexBody> 17 + </DocsIndexCard> 18 + 19 + <DocsIndexCard href="/docs/encryption-keys" icon="lock"> 20 + <DocsIndexTitle>Encryption & Keys</DocsIndexTitle> 21 + <DocsIndexBody> 22 + How end-to-end encryption works in Opake and how your keys are managed. 23 + </DocsIndexBody> 24 + </DocsIndexCard> 25 + 26 + <DocsIndexCard href="/docs/sharing-dids" icon="share"> 27 + <DocsIndexTitle>Sharing & DIDs</DocsIndexTitle> 28 + <DocsIndexBody> 29 + Share files using decentralised identifiers without a central authority. 30 + </DocsIndexBody> 31 + </DocsIndexCard> 32 + 33 + <DocsIndexCard href="/docs/keyrings" icon="group"> 34 + <DocsIndexTitle>Keyrings & Groups</DocsIndexTitle> 35 + <DocsIndexBody> 36 + Manage secure group sharing for families, teams, and research groups. 37 + </DocsIndexBody> 38 + </DocsIndexCard> 39 + 40 + <DocsIndexCard href="/docs/cli" icon="terminal"> 41 + <DocsIndexTitle>The CLI Manual</DocsIndexTitle> 42 + <DocsIndexBody> 43 + Command-line reference for managing your vault, grants, and identity. 44 + </DocsIndexBody> 45 + </DocsIndexCard> 46 + 47 + <DocsIndexCard href="/docs/pairing" icon="pairing"> 48 + <DocsIndexTitle>Multi-Device Magic</DocsIndexTitle> 49 + <DocsIndexBody> 50 + Securely transfer your identity keypair to new devices using your PDS as a relay. 51 + </DocsIndexBody> 52 + </DocsIndexCard> 53 + 54 + <DocsIndexCard href="/docs/glossary" icon="book"> 55 + <DocsIndexTitle>Glossary</DocsIndexTitle> 56 + <DocsIndexBody> 57 + A quick-hit reference for the terminology and acronyms we use in Opake. 58 + </DocsIndexBody> 59 + </DocsIndexCard> 60 + 61 + <DocsIndexCard href="/faq" icon="question"> 62 + <DocsIndexTitle>FAQ</DocsIndexTitle> 63 + <DocsIndexBody>Common questions about privacy, security, and how Opake compares to alternatives.</DocsIndexBody> 64 + </DocsIndexCard> 65 + </DocsIndexGrid>
+79
web/src/content/docs/keyrings.mdx
··· 1 + <ChapterHeader title="Keyrings: Group Privacy" /> 2 + 3 + ## (Coming soon) 4 + 5 + <Lead> 6 + Direct sharing is great for one-off files, but what if you have a folder for your family, your 7 + coworkers, or your research group? This is where **Keyrings** come in. 8 + </Lead> 9 + 10 + ## The "Group Key" Concept 11 + 12 + In a traditional encrypted app, if you want to share a folder with 10 people, you have to encrypt every file for 10 different public keys. If you add an 11th person, you have to re-encrypt everything. This is slow, inefficient, and doesn't scale. 13 + 14 + Opake solves this with **Keyrings**. 15 + 16 + A Keyring is a named group that has its own **Group Key (GK)**. Instead of encrypting files for individuals, you encrypt them for the Keyring. 17 + 18 + 1. The file's `Content Key` is wrapped under the **Group Key**. 19 + 2. The **Group Key** itself is then wrapped for each individual member of the group. 20 + 21 + <Callout type="info"> 22 + **The Result:** When a new member joins the group, we only need to wrap the Group Key for them 23 + *once*. They immediately gain access to every file ever encrypted for that Keyring. 24 + </Callout> 25 + 26 + --- 27 + 28 + ## 1. Creating a Keyring 29 + 30 + A Keyring is its own record on the AT Protocol (`app.opake.keyring`). 31 + 32 + <PlatformToggle> 33 + <PlatformTab name="Web App"> 34 + 35 + 1. Click the **(+)** icon in the Sidebar and select **New Keyring**. 36 + 2. Give it a name (e.g., "Family Photos"). 37 + 3. Opake generates a new Group Key, wraps it to your public key, and publishes the record. 38 + 39 + </PlatformTab> 40 + <PlatformTab name="CLI"> 41 + <CodeBlock language="sh">opake keyring create "Family Photos"</CodeBlock> 42 + </PlatformTab> 43 + </PlatformToggle> 44 + 45 + ## 2. Managing Membership 46 + 47 + Adding and removing members is a cryptographic operation. 48 + 49 + ### Adding a Member 50 + 51 + When you add someone to a Keyring, you are simply taking the Group Key and wrapping it to their public key. 52 + 53 + - **Effect:** They can now open the "Group Lockbox" and decrypt any file associated with that Keyring. 54 + 55 + ### Removing a Member (Key Rotation) 56 + 57 + This is the most complex part of the system. To ensure the removed member can't access _future_ files, Opake performs a **Key Rotation**: 58 + 59 + 1. It generates a **NEW** Group Key (`GK_n+1`). 60 + 2. it wraps this new key for all _remaining_ members. 61 + 3. It keeps a history of the old keys so that older files can still be decrypted by the remaining group. 62 + 63 + <Callout type="warning"> 64 + **Forward Secrecy Reality:** Just like with [Grants](/docs/sharing-dids), removing someone from a 65 + Keyring prevents them from accessing *future* files. If they already downloaded and decrypted old 66 + files, those files remain in their possession. 67 + </Callout> 68 + 69 + --- 70 + 71 + ## 3. Uploading to a Keyring 72 + 73 + When you upload a file, you can choose to associate it with a Keyring instead of an individual person. 74 + 75 + <CodeBlock language="sh">opake upload "budget.pdf" --keyring "Family Photos"</CodeBlock> 76 + 77 + Every member of that Keyring will see the file in their [Inbox](/docs/sharing-dids) and can decrypt it instantly. 78 + 79 + Ready for a quick reference on all these terms? Check the [Glossary](/docs/glossary).
+71
web/src/content/docs/pairing.mdx
··· 1 + <ChapterHeader title="A Secure Handshake" /> 2 + 3 + <Lead> 4 + Your identity in Opake isn't a username and password; it's a private key that stays with you. When 5 + you get a new phone or laptop, you need a way to pass that key to your new device without 6 + anyone—including the network—ever catching a glimpse. 7 + </Lead> 8 + 9 + ## The Problem: Identity vs. Access 10 + 11 + Traditional apps "sync" your data by sending a master copy to a central server. If that server is compromised, your privacy vanishes. 12 + 13 + Opake uses **Device Pairing**. Think of this as a private, one-on-one conversation between two of your own devices. They perform a cryptographic handshake to securely transfer your encryption identity so it never touches the internet in a readable form. 14 + 15 + <Callout type="info"> 16 + **The Silent Messenger:** We use your Personal Data Server (PDS) as a "dumb relay." It passes the 17 + encrypted messages back and forth, but it doesn't speak the language and has no way of knowing 18 + what’s inside the envelopes. 19 + </Callout> 20 + 21 + --- 22 + 23 + ## The Pairing Process 24 + 25 + This process ensures that your private keys are only ever unlocked on the devices you personally hold. 26 + 27 + ### 1. The Request (New Device) 28 + 29 + On your new phone or laptop, you'll initiate the pairing. Your device generates a temporary "invitation" and publishes it to your PDS. This invitation includes a one-time lock that only this specific new device can open. 30 + 31 + <PlatformToggle> 32 + <PlatformTab name="Web App"> 33 + After signing in, you will be given the option to pair with another device. On the phone or 34 + laptop where you've already signed in before, go to "Devices" to approve the new request. Once 35 + finished, both devices can be used to invite future devices. 36 + </PlatformTab> 37 + <PlatformTab name="CLI"> 38 + <CodeBlock language="sh">opake pair request</CodeBlock> 39 + </PlatformTab> 40 + </PlatformToggle> 41 + 42 + ### 2. The Approval (Existing Device) 43 + 44 + Your current device will see a notification that a new guest is asking to join your cabinet. 45 + 46 + When you click **Approve**, your device: 47 + 48 + 1. Fetches the invitation and the new device's temporary lock. 49 + 2. Uses a secure "handshake" to create a shared secret between the two devices. 50 + 3. Wraps your private identity keys inside that secret. 51 + 4. Posts the locked package back to your PDS. 52 + 53 + ### 3. The Completion (New Device) 54 + 55 + The new device picks up the package, unlocks it using its temporary key, and saves your identity. It then "shreds" the invitation and response records from your PDS, leaving no trail behind. 56 + 57 + --- 58 + 59 + ## Why is this safe? 60 + 61 + Even if someone were monitoring your PDS at the exact moment of the handshake, they could not steal your keys. 62 + 63 + The package is scrambled using high-grade encryption that is physically unreadable to anyone except the device that started the request. Only the two devices involved in the handshake hold the necessary "strength" to decode the identity. 64 + 65 + <Callout type="warning"> 66 + **The Human Element:** Always verify that the pairing request you are approving is actually from 67 + your own device. Approving a request is like handing over a physical key to your cabinet—only do 68 + it for devices you own and trust. 69 + </Callout> 70 + 71 + Ready to learn about the foundation of all this? Read about the [AT Protocol](/docs/at-protocol).
+59
web/src/content/docs/sharing-dids.mdx
··· 1 + <ChapterHeader title="Sharing & DIDs" /> 2 + 3 + <Lead> 4 + In the traditional cloud, sharing a file means granting a server permission to show your data to 5 + someone else. In Opake, sharing means giving a key to your file to another user. 6 + </Lead> 7 + 8 + ## The "Grant" Model 9 + 10 + When you share a file with someone in Opake, you are creating a **Grant**. 11 + 12 + Think of a Grant as a small, specialized lockbox. Inside this box is the `Content Key` for your file. This lockbox is specially designed so that only the recipient's private key can open it. Once you've created this box, you leave it on the network. The recipient can then pick it up, open it with their key, and use the `Content Key` to decrypt the file directly from your PDS. 13 + 14 + <Callout type="info"> 15 + **Zero Server Involvement:** Your PDS (and the recipient's PDS) act only as couriers. They never 16 + see the key, and they never see the file content. 17 + </Callout> 18 + 19 + --- 20 + 21 + ## 1. Sharing with a Handle 22 + 23 + To share a file, you just need the recipient's AT Protocol handle (e.g., `@bob.bsky.social`). 24 + 25 + <PlatformToggle> 26 + <PlatformTab name="Web App"> 27 + 28 + 1. Select a file in your cabinet. 29 + 2. Click the **Share** icon. 30 + 3. Enter the recipient's handle. 31 + 4. Opake resolves their handle to a DID, fetches their public encryption key, wraps the file key, and publishes the Grant record. 32 + 33 + </PlatformTab> 34 + <PlatformTab name="CLI"> 35 + <CodeBlock language="sh">opake share photo.jpg bob.bsky.social</CodeBlock> 36 + </PlatformTab> 37 + </PlatformToggle> 38 + 39 + ## 2. Why DIDs Matter 40 + 41 + You might know your friend as `@bob.bsky.social`, but Opake knows them as `did:plc:z724xy...`. 42 + 43 + A handle is just a nickname that can change. A **DID (Decentralized Identifier)** is a permanent, cryptographic ID. By sharing to a DID, the access remains valid even if your friend moves to a different PDS or changes their handle. 44 + 45 + <Callout type="warning"> 46 + **Public Keys are Public:** Opake automatically publishes your public encryption key to your PDS 47 + when you log in. This is how others can wrap files to you without needing to ask for your 48 + "address" first. 49 + </Callout> 50 + 51 + --- 52 + 53 + ## 3. Revoking Access 54 + 55 + If you want to stop sharing a file, you simply delete the Grant record. 56 + 57 + However, because Opake is truly decentralized, there is a nuance to revocation. Once a recipient has decrypted a file, they have a copy of the plaintext. Deleting a Grant prevents them from fetching _future_ updates or re-downloading the file if they lose their local copy, but it cannot "reach out" and delete the data from their physical device. 58 + 59 + Ready to see how to manage your identity across multiple devices? Read about [Multi-Device Magic](/docs/pairing).
+62
web/src/content/faq.mdx
··· 1 + <HeroSection> 2 + <HeroHeadline> 3 + Frequently Asked <Highlight>Questions</Highlight> 4 + </HeroHeadline> 5 + <HeroSubtext>Everything you wanted to know about Opake but were too polite to ask.</HeroSubtext> 6 + </HeroSection> 7 + 8 + <FaqSection> 9 + <FaqItem question="Is Opake free?"> 10 + Opake itself is open-source and free to use. However, you need a **Personal Data Server (PDS)** 11 + to store your files. While a PDS doesn't inherently limit your storage, Opake defaults to 12 + smaller limits out of **politeness** to providers we don't own. You (or your provider) can 13 + increase these limits by uploading specific configuration files to your PDS. 14 + </FaqItem> 15 + 16 + <FaqItem question="Where is my data actually stored?"> 17 + Opake uses a **bring-your-own-storage** design. Your encrypted files live on your PDS. In the 18 + future, we may offer managed storage options, but the goal is always to keep you in control of 19 + where your bits live. 20 + </FaqItem> 21 + 22 + <FaqItem question="Can Opake see my files?"> 23 + **No.** Encryption happens entirely on your device (browser or CLI) before any data is sent to 24 + internet. We never see the contents of your files, your filenames, your tags, or your private 25 + keys. To us, your data is just opaque noise. 26 + </FaqItem> 27 + 28 + <FaqItem question="Can I share files with people who don't use Opake?"> 29 + Because of how our [Encryption & Keys](/docs/encryption-keys) work, the recipient *must* have an 30 + AT Protocol identity (a DID) to receive a secure sharing [Grant](/docs/sharing-dids). This is a 31 + feature, not a bug. It ensures that the file key is wrapped specifically to their public key. If 32 + you need to share with a total outsider, you'll need to help them join the network first—it's 33 + worth it for the privacy. 34 + </FaqItem> 35 + 36 + <FaqItem question="What happens if I lose my device?"> 37 + If you lose your device and haven't backed up your private keys, **your data is lost forever**. 38 + Because Opake is end-to-end encrypted with no central authority, there is no "Forgot Password" or 39 + account recovery. We recommend using our [Device Pairing](/docs/pairing) feature to keep your 40 + identity on multiple trusted devices. 41 + </FaqItem> 42 + 43 + <FaqItem question="Is this a blockchain?"> 44 + **No.** Opake is built on the [AT Protocol](/docs/at-protocol), which is a federated social 45 + networking protocol. It uses cryptographic signatures and DIDs for identity, but it does not use a 46 + proof-of-work blockchain or tokens. 47 + </FaqItem> 48 + 49 + <FaqItem question="How do I contribute?"> 50 + You can [View the Sourcecode on Tangled](https://tangled.org/sans-self.org/opake.app) or [report issues](https://tangled.org/sans-self.org/opake.app/issues) there. We are built for the community, by the community. 51 + </FaqItem> 52 + </FaqSection> 53 + 54 + <CenterAction 55 + headline="Still curious?" 56 + subtext="The handbook covers everything from encryption to keyrings to device pairing." 57 + > 58 + <TextLink href="/docs"> 59 + Read the full Handbook 60 + <ArrowRightIcon /> 61 + </TextLink> 62 + </CenterAction>
-3
web/src/content/landing.mdx
··· 1 - Opake exists because privacy and collaboration should not be a tradeoff. 2 - Your files — encrypted, owned, shared on your terms — through 3 - decentralised identity, with no central authority in between.
+72
web/src/content/troubleshooting.mdx
··· 1 + <ChapterHeader title="Troubleshooting & Common Issues" /> 2 + 3 + <Lead> 4 + Opake is a decentralized protocol, which means sometimes things can get a little messy. Here is 5 + how to fix the most common issues you might run into. 6 + </Lead> 7 + 8 + ## Login & Authentication 9 + 10 + ### "Invalid Handle" or "PDS not found" 11 + 12 + If Opake can't find your account, it's usually because your handle (e.g., `you.bsky.social`) can't be resolved. 13 + 14 + - **Check the spelling:** Ensure there are no typos in your handle. 15 + - **Check your PDS status:** Sometimes the PDS provider you use might be down or having trouble responding to resolution requests. 16 + - **Legacy Auth:** If your PDS doesn't support OAuth yet, you might need to use the `--legacy` flag in the CLI. 17 + 18 + ### OAuth Redirect Issues 19 + 20 + If the browser window opens for login but never redirects you back to Opake: 21 + 22 + - **Check for ad-blockers:** Some aggressive browser extensions might block the redirect URL. 23 + - **CLI specific:** The CLI uses a temporary server on `127.0.0.1`. Ensure your firewall is not blocking local loopback connections. 24 + 25 + --- 26 + 27 + ## Files & Uploads 28 + 29 + ### Upload Fails halfway (Storage Limits) 30 + 31 + Large files can be tricky. Opake uploads your files as single blobs to the AT Protocol. 32 + 33 + - **Politeness Limits:** By default, Opake limits the size of blobs it will upload to respect PDS 34 + providers we don't own. 35 + - **Increasing Storage:** You (or your PDS administrator) can increase these limits by uploading 36 + specific configuration records to your repository. Check our [Technical Spec](/docs/protocol) 37 + for the exact schema. 38 + - **Network Stability:** If your connection drops, the upload may fail. Try again when you have a 39 + more stable connection. 40 + 41 + ### "Unable to Decrypt File" 42 + 43 + This is the most serious error. It means Opake cannot open the "lockbox" for that file. 44 + 45 + - **Wrong Account:** Ensure you are logged into the correct account (the one the file was shared with). 46 + - **Missing Keys:** If you recently logged in on a new device but didn't perform a [Device Pairing](/docs/pairing), you won't have the private keys necessary to decrypt existing files. 47 + 48 + --- 49 + 50 + ## Sharing & Discovery 51 + 52 + ### "Recipient not found" 53 + 54 + If you can't share a file with someone: 55 + 56 + - **Handle vs. DID:** Ensure the handle is correct. 57 + - **Public Key Missing:** The recipient must have logged into Opake at least once to publish their [Public Encryption Key](/docs/encryption-keys). If they haven't done this, you cannot "wrap" a file to them. 58 + 59 + ### "I don't see shared files in my inbox" 60 + 61 + Opake uses an **AppView** to index sharing grants. 62 + 63 + - **Index Lag:** Sometimes it takes a few moments for the firehose to catch up. 64 + - **AppView Status:** Check if the AppView service is healthy. If the indexer is down, your inbox will appear empty even if the files exist. 65 + 66 + --- 67 + 68 + <Callout type="info"> 69 + **Still Stuck?** If you've found a genuine bug, please [report it on 70 + Tangled](https://tangled.org/sans-self.org/opake.app/issues). Include any error messages and 71 + details about your environment (Web or CLI). 72 + </Callout>
+71 -2
web/src/index.css
··· 1 1 @import "tailwindcss"; 2 2 @plugin "daisyui"; 3 3 4 + /* ─── Fallback font metric overrides (eliminates layout shift on swap) ──── */ 5 + 6 + @font-face { 7 + font-family: "Inter Fallback"; 8 + src: local("Arial"), local("Helvetica Neue"), local("Helvetica"); 9 + size-adjust: 111.93%; 10 + ascent-override: 86.55%; 11 + descent-override: 21.55%; 12 + line-gap-override: 0%; 13 + } 14 + 15 + @font-face { 16 + font-family: "Cormorant Garamond Fallback"; 17 + src: local("Times New Roman"), local("Times"); 18 + size-adjust: 98.32%; 19 + ascent-override: 93.98%; 20 + descent-override: 29.19%; 21 + line-gap-override: 0%; 22 + } 23 + 4 24 /* ─── Self-hosted fonts ──────────────────────────────────────────────────── */ 5 25 6 26 @font-face { ··· 145 165 /* ─── Design tokens ───────────────────────────────────────────────────────── */ 146 166 147 167 @theme { 148 - --font-display: "Cormorant Garamond", serif; 149 - --font-sans: "Inter", sans-serif; 168 + --font-display: "Cormorant Garamond", "Cormorant Garamond Fallback", serif; 169 + --font-sans: "Inter", "Inter Fallback", sans-serif; 150 170 151 171 /* Text scale */ 152 172 --text-ui: 0.8125rem; /* 13px — primary UI text */ ··· 260 280 } 261 281 .prose strong { 262 282 font-weight: 600; 283 + } 284 + .prose h2 { 285 + font-family: var(--font-display); 286 + font-weight: 400; 287 + font-size: clamp(1.3rem, 2.5vw, 1.8rem); 288 + line-height: 1.2; 289 + letter-spacing: -0.01em; 290 + margin-top: 2.5em; 291 + margin-bottom: 0.75em; 292 + color: var(--color-base-content); 293 + } 294 + .prose h3 { 295 + font-size: 1.05rem; 296 + font-weight: 600; 297 + margin-top: 1.8em; 298 + margin-bottom: 0.5em; 299 + color: var(--color-base-content); 300 + } 301 + .prose ul, 302 + .prose ol { 303 + margin-block: 0.75em; 304 + padding-left: 1.5em; 305 + } 306 + .prose li { 307 + margin-block: 0.25em; 308 + } 309 + .prose hr { 310 + border: none; 311 + border-top: 1px solid var(--color-border-accent); 312 + opacity: 0.4; 313 + margin-block: 2em; 314 + } 315 + .prose code:not(pre code) { 316 + font-size: 0.85em; 317 + padding: 0.15em 0.35em; 318 + border-radius: 0.25rem; 319 + background: var(--color-base-300); 320 + } 321 + .prose blockquote { 322 + border-left: 3px solid var(--color-border-accent); 323 + padding-left: 1em; 324 + margin-block: 1em; 325 + color: var(--color-text-muted); 326 + font-style: italic; 327 + } 328 + .prose pre { 329 + margin-block: 1em; 330 + border-radius: 0.5rem; 331 + overflow-x: auto; 263 332 } 264 333 }
+70
web/src/lib/docs-registry.ts
··· 1 + import type { IconName } from "@/components/content/icons"; 2 + 3 + export interface DocMeta { 4 + readonly slug: string; 5 + readonly title: string; 6 + readonly description: string; 7 + readonly icon: IconName; 8 + } 9 + 10 + /** 11 + * Single source of truth for documentation section metadata. 12 + * Used by both public docs routes and cabinet docs routes. 13 + */ 14 + export const DOCS_REGISTRY: readonly DocMeta[] = [ 15 + { 16 + slug: "getting-started", 17 + title: "Getting Started", 18 + icon: "sparkles", 19 + description: 20 + "Set up your cabinet, create your first encrypted file, and explore the interface.", 21 + }, 22 + { 23 + slug: "at-protocol", 24 + title: "AT Protocol", 25 + icon: "network", 26 + description: "The open standard powering Opake — identity, data portability, and federation.", 27 + }, 28 + { 29 + slug: "encryption-keys", 30 + title: "Encryption & Keys", 31 + icon: "lock", 32 + description: "How end-to-end encryption works in Opake and how your keys are managed.", 33 + }, 34 + { 35 + slug: "sharing-dids", 36 + title: "Sharing & DIDs", 37 + icon: "share", 38 + description: "Share files using decentralised identifiers without a central authority.", 39 + }, 40 + { 41 + slug: "keyrings", 42 + title: "Keyrings & Groups", 43 + icon: "group", 44 + description: "Manage secure group sharing for families, teams, and research groups.", 45 + }, 46 + { 47 + slug: "pairing", 48 + title: "Multi-Device Magic", 49 + icon: "pairing", 50 + description: 51 + "Securely transfer your identity keypair to new devices using your PDS as a relay.", 52 + }, 53 + { 54 + slug: "glossary", 55 + title: "Glossary", 56 + icon: "book", 57 + description: "A quick-hit reference for the terminology and acronyms we use in Opake.", 58 + }, 59 + { 60 + slug: "faq", 61 + title: "FAQ", 62 + icon: "question", 63 + description: 64 + "Common questions about privacy, security, and how Opake compares to alternatives.", 65 + }, 66 + ]; 67 + 68 + export function findDoc(slug: string): DocMeta | undefined { 69 + return DOCS_REGISTRY.find((d) => d.slug === slug); 70 + }
+1 -1
web/src/lib/format.ts
··· 1 - // Display formatting utilities for the cabinet UI. 1 + // Display formatting utilities for your cabinet UI. 2 2 3 3 import type { FileType } from "@/components/cabinet/types"; 4 4
+198 -40
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 PublicRouteImport } from './routes/_public' 12 13 import { Route as DevicesRouteRouteImport } from './routes/devices/route' 13 14 import { Route as CabinetRouteRouteImport } from './routes/cabinet/route' 14 - import { Route as IndexRouteImport } from './routes/index' 15 15 import { Route as DevicesIndexRouteImport } from './routes/devices/index' 16 16 import { Route as CabinetIndexRouteImport } from './routes/cabinet/index' 17 + import { Route as PublicIndexRouteImport } from './routes/_public/index' 17 18 import { Route as DevicesOauthCallbackRouteImport } from './routes/devices/oauth-callback' 18 19 import { Route as DevicesLoginRouteImport } from './routes/devices/login' 19 20 import { Route as DevicesCliCallbackRouteImport } from './routes/devices/cli-callback' ··· 21 22 import { Route as CabinetSharedRouteImport } from './routes/cabinet/shared' 22 23 import { Route as CabinetSettingsRouteImport } from './routes/cabinet/settings' 23 24 import { Route as CabinetEncryptedRouteImport } from './routes/cabinet/encrypted' 24 - import { Route as CabinetDocsRouteImport } from './routes/cabinet/docs' 25 + import { Route as PublicTroubleshootingRouteImport } from './routes/_public/troubleshooting' 26 + import { Route as PublicFaqRouteImport } from './routes/_public/faq' 25 27 import { Route as CabinetFilesRouteRouteImport } from './routes/cabinet/files/route' 28 + import { Route as CabinetDocsRouteRouteImport } from './routes/cabinet/docs/route' 26 29 import { Route as CabinetFilesIndexRouteImport } from './routes/cabinet/files/index' 30 + import { Route as CabinetDocsIndexRouteImport } from './routes/cabinet/docs/index' 31 + import { Route as PublicDocsIndexRouteImport } from './routes/_public/docs/index' 27 32 import { Route as DevicesPairRequestRouteImport } from './routes/devices/pair.request' 28 33 import { Route as DevicesPairAcceptRouteImport } from './routes/devices/pair.accept' 29 34 import { Route as CabinetFilesSplatRouteImport } from './routes/cabinet/files/$' 35 + import { Route as CabinetDocsSlugRouteImport } from './routes/cabinet/docs/$slug' 36 + import { Route as PublicDocsSlugRouteImport } from './routes/_public/docs/$slug' 30 37 38 + const PublicRoute = PublicRouteImport.update({ 39 + id: '/_public', 40 + getParentRoute: () => rootRouteImport, 41 + } as any) 31 42 const DevicesRouteRoute = DevicesRouteRouteImport.update({ 32 43 id: '/devices', 33 44 path: '/devices', ··· 38 49 path: '/cabinet', 39 50 getParentRoute: () => rootRouteImport, 40 51 } as any) 41 - const IndexRoute = IndexRouteImport.update({ 42 - id: '/', 43 - path: '/', 44 - getParentRoute: () => rootRouteImport, 45 - } as any) 46 52 const DevicesIndexRoute = DevicesIndexRouteImport.update({ 47 53 id: '/', 48 54 path: '/', ··· 52 58 id: '/', 53 59 path: '/', 54 60 getParentRoute: () => CabinetRouteRoute, 61 + } as any) 62 + const PublicIndexRoute = PublicIndexRouteImport.update({ 63 + id: '/', 64 + path: '/', 65 + getParentRoute: () => PublicRoute, 55 66 } as any) 56 67 const DevicesOauthCallbackRoute = DevicesOauthCallbackRouteImport.update({ 57 68 id: '/oauth-callback', ··· 88 99 path: '/encrypted', 89 100 getParentRoute: () => CabinetRouteRoute, 90 101 } as any) 91 - const CabinetDocsRoute = CabinetDocsRouteImport.update({ 92 - id: '/docs', 93 - path: '/docs', 94 - getParentRoute: () => CabinetRouteRoute, 102 + const PublicTroubleshootingRoute = PublicTroubleshootingRouteImport.update({ 103 + id: '/troubleshooting', 104 + path: '/troubleshooting', 105 + getParentRoute: () => PublicRoute, 106 + } as any) 107 + const PublicFaqRoute = PublicFaqRouteImport.update({ 108 + id: '/faq', 109 + path: '/faq', 110 + getParentRoute: () => PublicRoute, 95 111 } as any) 96 112 const CabinetFilesRouteRoute = CabinetFilesRouteRouteImport.update({ 97 113 id: '/files', 98 114 path: '/files', 99 115 getParentRoute: () => CabinetRouteRoute, 100 116 } as any) 117 + const CabinetDocsRouteRoute = CabinetDocsRouteRouteImport.update({ 118 + id: '/docs', 119 + path: '/docs', 120 + getParentRoute: () => CabinetRouteRoute, 121 + } as any) 101 122 const CabinetFilesIndexRoute = CabinetFilesIndexRouteImport.update({ 102 123 id: '/', 103 124 path: '/', 104 125 getParentRoute: () => CabinetFilesRouteRoute, 105 126 } as any) 127 + const CabinetDocsIndexRoute = CabinetDocsIndexRouteImport.update({ 128 + id: '/', 129 + path: '/', 130 + getParentRoute: () => CabinetDocsRouteRoute, 131 + } as any) 132 + const PublicDocsIndexRoute = PublicDocsIndexRouteImport.update({ 133 + id: '/docs/', 134 + path: '/docs/', 135 + getParentRoute: () => PublicRoute, 136 + } as any) 106 137 const DevicesPairRequestRoute = DevicesPairRequestRouteImport.update({ 107 138 id: '/pair/request', 108 139 path: '/pair/request', ··· 118 149 path: '/$', 119 150 getParentRoute: () => CabinetFilesRouteRoute, 120 151 } as any) 152 + const CabinetDocsSlugRoute = CabinetDocsSlugRouteImport.update({ 153 + id: '/$slug', 154 + path: '/$slug', 155 + getParentRoute: () => CabinetDocsRouteRoute, 156 + } as any) 157 + const PublicDocsSlugRoute = PublicDocsSlugRouteImport.update({ 158 + id: '/docs/$slug', 159 + path: '/docs/$slug', 160 + getParentRoute: () => PublicRoute, 161 + } as any) 121 162 122 163 export interface FileRoutesByFullPath { 123 - '/': typeof IndexRoute 124 164 '/cabinet': typeof CabinetRouteRouteWithChildren 125 165 '/devices': typeof DevicesRouteRouteWithChildren 166 + '/': typeof PublicIndexRoute 167 + '/cabinet/docs': typeof CabinetDocsRouteRouteWithChildren 126 168 '/cabinet/files': typeof CabinetFilesRouteRouteWithChildren 127 - '/cabinet/docs': typeof CabinetDocsRoute 169 + '/faq': typeof PublicFaqRoute 170 + '/troubleshooting': typeof PublicTroubleshootingRoute 128 171 '/cabinet/encrypted': typeof CabinetEncryptedRoute 129 172 '/cabinet/settings': typeof CabinetSettingsRoute 130 173 '/cabinet/shared': typeof CabinetSharedRoute ··· 134 177 '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 135 178 '/cabinet/': typeof CabinetIndexRoute 136 179 '/devices/': typeof DevicesIndexRoute 180 + '/docs/$slug': typeof PublicDocsSlugRoute 181 + '/cabinet/docs/$slug': typeof CabinetDocsSlugRoute 137 182 '/cabinet/files/$': typeof CabinetFilesSplatRoute 138 183 '/devices/pair/accept': typeof DevicesPairAcceptRoute 139 184 '/devices/pair/request': typeof DevicesPairRequestRoute 185 + '/docs/': typeof PublicDocsIndexRoute 186 + '/cabinet/docs/': typeof CabinetDocsIndexRoute 140 187 '/cabinet/files/': typeof CabinetFilesIndexRoute 141 188 } 142 189 export interface FileRoutesByTo { 143 - '/': typeof IndexRoute 144 - '/cabinet/docs': typeof CabinetDocsRoute 190 + '/faq': typeof PublicFaqRoute 191 + '/troubleshooting': typeof PublicTroubleshootingRoute 145 192 '/cabinet/encrypted': typeof CabinetEncryptedRoute 146 193 '/cabinet/settings': typeof CabinetSettingsRoute 147 194 '/cabinet/shared': typeof CabinetSharedRoute ··· 149 196 '/devices/cli-callback': typeof DevicesCliCallbackRoute 150 197 '/devices/login': typeof DevicesLoginRoute 151 198 '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 199 + '/': typeof PublicIndexRoute 152 200 '/cabinet': typeof CabinetIndexRoute 153 201 '/devices': typeof DevicesIndexRoute 202 + '/docs/$slug': typeof PublicDocsSlugRoute 203 + '/cabinet/docs/$slug': typeof CabinetDocsSlugRoute 154 204 '/cabinet/files/$': typeof CabinetFilesSplatRoute 155 205 '/devices/pair/accept': typeof DevicesPairAcceptRoute 156 206 '/devices/pair/request': typeof DevicesPairRequestRoute 207 + '/docs': typeof PublicDocsIndexRoute 208 + '/cabinet/docs': typeof CabinetDocsIndexRoute 157 209 '/cabinet/files': typeof CabinetFilesIndexRoute 158 210 } 159 211 export interface FileRoutesById { 160 212 __root__: typeof rootRouteImport 161 - '/': typeof IndexRoute 162 213 '/cabinet': typeof CabinetRouteRouteWithChildren 163 214 '/devices': typeof DevicesRouteRouteWithChildren 215 + '/_public': typeof PublicRouteWithChildren 216 + '/cabinet/docs': typeof CabinetDocsRouteRouteWithChildren 164 217 '/cabinet/files': typeof CabinetFilesRouteRouteWithChildren 165 - '/cabinet/docs': typeof CabinetDocsRoute 218 + '/_public/faq': typeof PublicFaqRoute 219 + '/_public/troubleshooting': typeof PublicTroubleshootingRoute 166 220 '/cabinet/encrypted': typeof CabinetEncryptedRoute 167 221 '/cabinet/settings': typeof CabinetSettingsRoute 168 222 '/cabinet/shared': typeof CabinetSharedRoute ··· 170 224 '/devices/cli-callback': typeof DevicesCliCallbackRoute 171 225 '/devices/login': typeof DevicesLoginRoute 172 226 '/devices/oauth-callback': typeof DevicesOauthCallbackRoute 227 + '/_public/': typeof PublicIndexRoute 173 228 '/cabinet/': typeof CabinetIndexRoute 174 229 '/devices/': typeof DevicesIndexRoute 230 + '/_public/docs/$slug': typeof PublicDocsSlugRoute 231 + '/cabinet/docs/$slug': typeof CabinetDocsSlugRoute 175 232 '/cabinet/files/$': typeof CabinetFilesSplatRoute 176 233 '/devices/pair/accept': typeof DevicesPairAcceptRoute 177 234 '/devices/pair/request': typeof DevicesPairRequestRoute 235 + '/_public/docs/': typeof PublicDocsIndexRoute 236 + '/cabinet/docs/': typeof CabinetDocsIndexRoute 178 237 '/cabinet/files/': typeof CabinetFilesIndexRoute 179 238 } 180 239 export interface FileRouteTypes { 181 240 fileRoutesByFullPath: FileRoutesByFullPath 182 241 fullPaths: 183 - | '/' 184 242 | '/cabinet' 185 243 | '/devices' 244 + | '/' 245 + | '/cabinet/docs' 186 246 | '/cabinet/files' 187 - | '/cabinet/docs' 247 + | '/faq' 248 + | '/troubleshooting' 188 249 | '/cabinet/encrypted' 189 250 | '/cabinet/settings' 190 251 | '/cabinet/shared' ··· 194 255 | '/devices/oauth-callback' 195 256 | '/cabinet/' 196 257 | '/devices/' 258 + | '/docs/$slug' 259 + | '/cabinet/docs/$slug' 197 260 | '/cabinet/files/$' 198 261 | '/devices/pair/accept' 199 262 | '/devices/pair/request' 263 + | '/docs/' 264 + | '/cabinet/docs/' 200 265 | '/cabinet/files/' 201 266 fileRoutesByTo: FileRoutesByTo 202 267 to: 203 - | '/' 204 - | '/cabinet/docs' 268 + | '/faq' 269 + | '/troubleshooting' 205 270 | '/cabinet/encrypted' 206 271 | '/cabinet/settings' 207 272 | '/cabinet/shared' ··· 209 274 | '/devices/cli-callback' 210 275 | '/devices/login' 211 276 | '/devices/oauth-callback' 277 + | '/' 212 278 | '/cabinet' 213 279 | '/devices' 280 + | '/docs/$slug' 281 + | '/cabinet/docs/$slug' 214 282 | '/cabinet/files/$' 215 283 | '/devices/pair/accept' 216 284 | '/devices/pair/request' 285 + | '/docs' 286 + | '/cabinet/docs' 217 287 | '/cabinet/files' 218 288 id: 219 289 | '__root__' 220 - | '/' 221 290 | '/cabinet' 222 291 | '/devices' 292 + | '/_public' 293 + | '/cabinet/docs' 223 294 | '/cabinet/files' 224 - | '/cabinet/docs' 295 + | '/_public/faq' 296 + | '/_public/troubleshooting' 225 297 | '/cabinet/encrypted' 226 298 | '/cabinet/settings' 227 299 | '/cabinet/shared' ··· 229 301 | '/devices/cli-callback' 230 302 | '/devices/login' 231 303 | '/devices/oauth-callback' 304 + | '/_public/' 232 305 | '/cabinet/' 233 306 | '/devices/' 307 + | '/_public/docs/$slug' 308 + | '/cabinet/docs/$slug' 234 309 | '/cabinet/files/$' 235 310 | '/devices/pair/accept' 236 311 | '/devices/pair/request' 312 + | '/_public/docs/' 313 + | '/cabinet/docs/' 237 314 | '/cabinet/files/' 238 315 fileRoutesById: FileRoutesById 239 316 } 240 317 export interface RootRouteChildren { 241 - IndexRoute: typeof IndexRoute 242 318 CabinetRouteRoute: typeof CabinetRouteRouteWithChildren 243 319 DevicesRouteRoute: typeof DevicesRouteRouteWithChildren 320 + PublicRoute: typeof PublicRouteWithChildren 244 321 } 245 322 246 323 declare module '@tanstack/react-router' { 247 324 interface FileRoutesByPath { 325 + '/_public': { 326 + id: '/_public' 327 + path: '' 328 + fullPath: '/' 329 + preLoaderRoute: typeof PublicRouteImport 330 + parentRoute: typeof rootRouteImport 331 + } 248 332 '/devices': { 249 333 id: '/devices' 250 334 path: '/devices' ··· 259 343 preLoaderRoute: typeof CabinetRouteRouteImport 260 344 parentRoute: typeof rootRouteImport 261 345 } 262 - '/': { 263 - id: '/' 264 - path: '/' 265 - fullPath: '/' 266 - preLoaderRoute: typeof IndexRouteImport 267 - parentRoute: typeof rootRouteImport 268 - } 269 346 '/devices/': { 270 347 id: '/devices/' 271 348 path: '/' ··· 279 356 fullPath: '/cabinet/' 280 357 preLoaderRoute: typeof CabinetIndexRouteImport 281 358 parentRoute: typeof CabinetRouteRoute 359 + } 360 + '/_public/': { 361 + id: '/_public/' 362 + path: '/' 363 + fullPath: '/' 364 + preLoaderRoute: typeof PublicIndexRouteImport 365 + parentRoute: typeof PublicRoute 282 366 } 283 367 '/devices/oauth-callback': { 284 368 id: '/devices/oauth-callback' ··· 329 413 preLoaderRoute: typeof CabinetEncryptedRouteImport 330 414 parentRoute: typeof CabinetRouteRoute 331 415 } 332 - '/cabinet/docs': { 333 - id: '/cabinet/docs' 334 - path: '/docs' 335 - fullPath: '/cabinet/docs' 336 - preLoaderRoute: typeof CabinetDocsRouteImport 337 - parentRoute: typeof CabinetRouteRoute 416 + '/_public/troubleshooting': { 417 + id: '/_public/troubleshooting' 418 + path: '/troubleshooting' 419 + fullPath: '/troubleshooting' 420 + preLoaderRoute: typeof PublicTroubleshootingRouteImport 421 + parentRoute: typeof PublicRoute 422 + } 423 + '/_public/faq': { 424 + id: '/_public/faq' 425 + path: '/faq' 426 + fullPath: '/faq' 427 + preLoaderRoute: typeof PublicFaqRouteImport 428 + parentRoute: typeof PublicRoute 338 429 } 339 430 '/cabinet/files': { 340 431 id: '/cabinet/files' 341 432 path: '/files' 342 433 fullPath: '/cabinet/files' 343 434 preLoaderRoute: typeof CabinetFilesRouteRouteImport 435 + parentRoute: typeof CabinetRouteRoute 436 + } 437 + '/cabinet/docs': { 438 + id: '/cabinet/docs' 439 + path: '/docs' 440 + fullPath: '/cabinet/docs' 441 + preLoaderRoute: typeof CabinetDocsRouteRouteImport 344 442 parentRoute: typeof CabinetRouteRoute 345 443 } 346 444 '/cabinet/files/': { ··· 350 448 preLoaderRoute: typeof CabinetFilesIndexRouteImport 351 449 parentRoute: typeof CabinetFilesRouteRoute 352 450 } 451 + '/cabinet/docs/': { 452 + id: '/cabinet/docs/' 453 + path: '/' 454 + fullPath: '/cabinet/docs/' 455 + preLoaderRoute: typeof CabinetDocsIndexRouteImport 456 + parentRoute: typeof CabinetDocsRouteRoute 457 + } 458 + '/_public/docs/': { 459 + id: '/_public/docs/' 460 + path: '/docs' 461 + fullPath: '/docs/' 462 + preLoaderRoute: typeof PublicDocsIndexRouteImport 463 + parentRoute: typeof PublicRoute 464 + } 353 465 '/devices/pair/request': { 354 466 id: '/devices/pair/request' 355 467 path: '/pair/request' ··· 371 483 preLoaderRoute: typeof CabinetFilesSplatRouteImport 372 484 parentRoute: typeof CabinetFilesRouteRoute 373 485 } 486 + '/cabinet/docs/$slug': { 487 + id: '/cabinet/docs/$slug' 488 + path: '/$slug' 489 + fullPath: '/cabinet/docs/$slug' 490 + preLoaderRoute: typeof CabinetDocsSlugRouteImport 491 + parentRoute: typeof CabinetDocsRouteRoute 492 + } 493 + '/_public/docs/$slug': { 494 + id: '/_public/docs/$slug' 495 + path: '/docs/$slug' 496 + fullPath: '/docs/$slug' 497 + preLoaderRoute: typeof PublicDocsSlugRouteImport 498 + parentRoute: typeof PublicRoute 499 + } 374 500 } 375 501 } 376 502 503 + interface CabinetDocsRouteRouteChildren { 504 + CabinetDocsSlugRoute: typeof CabinetDocsSlugRoute 505 + CabinetDocsIndexRoute: typeof CabinetDocsIndexRoute 506 + } 507 + 508 + const CabinetDocsRouteRouteChildren: CabinetDocsRouteRouteChildren = { 509 + CabinetDocsSlugRoute: CabinetDocsSlugRoute, 510 + CabinetDocsIndexRoute: CabinetDocsIndexRoute, 511 + } 512 + 513 + const CabinetDocsRouteRouteWithChildren = 514 + CabinetDocsRouteRoute._addFileChildren(CabinetDocsRouteRouteChildren) 515 + 377 516 interface CabinetFilesRouteRouteChildren { 378 517 CabinetFilesSplatRoute: typeof CabinetFilesSplatRoute 379 518 CabinetFilesIndexRoute: typeof CabinetFilesIndexRoute ··· 388 527 CabinetFilesRouteRoute._addFileChildren(CabinetFilesRouteRouteChildren) 389 528 390 529 interface CabinetRouteRouteChildren { 530 + CabinetDocsRouteRoute: typeof CabinetDocsRouteRouteWithChildren 391 531 CabinetFilesRouteRoute: typeof CabinetFilesRouteRouteWithChildren 392 - CabinetDocsRoute: typeof CabinetDocsRoute 393 532 CabinetEncryptedRoute: typeof CabinetEncryptedRoute 394 533 CabinetSettingsRoute: typeof CabinetSettingsRoute 395 534 CabinetSharedRoute: typeof CabinetSharedRoute ··· 398 537 } 399 538 400 539 const CabinetRouteRouteChildren: CabinetRouteRouteChildren = { 540 + CabinetDocsRouteRoute: CabinetDocsRouteRouteWithChildren, 401 541 CabinetFilesRouteRoute: CabinetFilesRouteRouteWithChildren, 402 - CabinetDocsRoute: CabinetDocsRoute, 403 542 CabinetEncryptedRoute: CabinetEncryptedRoute, 404 543 CabinetSettingsRoute: CabinetSettingsRoute, 405 544 CabinetSharedRoute: CabinetSharedRoute, ··· 433 572 DevicesRouteRouteChildren, 434 573 ) 435 574 575 + interface PublicRouteChildren { 576 + PublicFaqRoute: typeof PublicFaqRoute 577 + PublicTroubleshootingRoute: typeof PublicTroubleshootingRoute 578 + PublicIndexRoute: typeof PublicIndexRoute 579 + PublicDocsSlugRoute: typeof PublicDocsSlugRoute 580 + PublicDocsIndexRoute: typeof PublicDocsIndexRoute 581 + } 582 + 583 + const PublicRouteChildren: PublicRouteChildren = { 584 + PublicFaqRoute: PublicFaqRoute, 585 + PublicTroubleshootingRoute: PublicTroubleshootingRoute, 586 + PublicIndexRoute: PublicIndexRoute, 587 + PublicDocsSlugRoute: PublicDocsSlugRoute, 588 + PublicDocsIndexRoute: PublicDocsIndexRoute, 589 + } 590 + 591 + const PublicRouteWithChildren = 592 + PublicRoute._addFileChildren(PublicRouteChildren) 593 + 436 594 const rootRouteChildren: RootRouteChildren = { 437 - IndexRoute: IndexRoute, 438 595 CabinetRouteRoute: CabinetRouteRouteWithChildren, 439 596 DevicesRouteRoute: DevicesRouteRouteWithChildren, 597 + PublicRoute: PublicRouteWithChildren, 440 598 } 441 599 export const routeTree = rootRouteImport 442 600 ._addFileChildren(rootRouteChildren)
+41
web/src/routes/__root.tsx
··· 1 1 import { 2 2 createRootRouteWithContext, 3 3 HeadContent, 4 + Link, 4 5 Outlet, 5 6 Scripts, 6 7 useRouter, 7 8 } from "@tanstack/react-router"; 8 9 import type { AuthSnapshot } from "@/stores/auth"; 9 10 import { ToastContainer } from "@/components/ToastContainer"; 11 + import { OpakeLogo } from "@/components/OpakeLogo"; 10 12 import css from "@/index.css?url"; 11 13 12 14 export interface RouterContext { ··· 19 21 <head> 20 22 <meta charSet="UTF-8" /> 21 23 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 24 + <link 25 + rel="preload" 26 + href="/fonts/inter-latin-normal.woff2" 27 + as="font" 28 + type="font/woff2" 29 + crossOrigin="" 30 + /> 31 + <link 32 + rel="preload" 33 + href="/fonts/cormorant-garamond-latin-normal.woff2" 34 + as="font" 35 + type="font/woff2" 36 + crossOrigin="" 37 + /> 22 38 <link rel="stylesheet" href={css} /> 23 39 <HeadContent /> 24 40 </head> ··· 62 78 ); 63 79 } 64 80 81 + function NotFound() { 82 + return ( 83 + <div className="bg-base-300 flex min-h-screen flex-col items-center justify-center gap-6 px-6 font-sans"> 84 + <OpakeLogo /> 85 + <div className="text-center"> 86 + <h1 className="font-display text-base-content mb-2 text-[clamp(2rem,5vw,3.4rem)] font-normal tracking-tight"> 87 + Page not found 88 + </h1> 89 + <p className="text-text-muted text-[0.95rem]"> 90 + The page you&rsquo;re looking for doesn&rsquo;t exist, or it moved. 91 + </p> 92 + </div> 93 + <div className="flex gap-3"> 94 + <Link to="/" className="btn btn-neutral btn-sm"> 95 + Back to home 96 + </Link> 97 + <Link to="/docs" className="btn btn-outline border-border-accent text-secondary btn-sm"> 98 + Read the docs 99 + </Link> 100 + </div> 101 + </div> 102 + ); 103 + } 104 + 65 105 export const Route = createRootRouteWithContext<RouterContext>()({ 66 106 head: () => ({ 67 107 meta: [ ··· 73 113 }), 74 114 component: RootLayout, 75 115 errorComponent: RootError, 116 + notFoundComponent: NotFound, 76 117 });
+199
web/src/routes/_public.tsx
··· 1 + import { createFileRoute, Link, Outlet } from "@tanstack/react-router"; 2 + import { ArrowRightIcon } from "@phosphor-icons/react"; 3 + import { OpakeLogo } from "@/components/OpakeLogo"; 4 + 5 + interface NavItem { 6 + readonly label: string; 7 + readonly href: string; 8 + readonly internal?: boolean; 9 + } 10 + 11 + const NAV_LINKS: readonly NavItem[] = [ 12 + { label: "FAQ", href: "/faq", internal: true }, 13 + { label: "About", href: "/#what-is-opake", internal: true }, 14 + { label: "How it works", href: "/#how-it-works", internal: true }, 15 + { label: "Handbook", href: "/docs/", internal: true }, 16 + { label: "Source code", href: "https://tangled.org/sans-self.org/opake.app" }, 17 + ]; 18 + 19 + interface FooterLink { 20 + readonly label: string; 21 + readonly href: string; 22 + readonly internal?: boolean; 23 + } 24 + 25 + interface FooterGroup { 26 + readonly heading: string; 27 + readonly links: readonly FooterLink[]; 28 + } 29 + 30 + const FOOTER_GROUPS: readonly FooterGroup[] = [ 31 + { 32 + heading: "Product", 33 + links: [ 34 + { label: "Open your cabinet", href: "/devices/login", internal: true }, 35 + { label: "Handbook", href: "/docs/", internal: true }, 36 + { label: "FAQ", href: "/faq", internal: true }, 37 + ], 38 + }, 39 + { 40 + heading: "Community", 41 + links: [{ label: "AT Protocol", href: "https://atproto.com" }], 42 + }, 43 + { 44 + heading: "Resources", 45 + links: [ 46 + { label: "Source Code", href: "https://tangled.org/sans-self.org/opake.app" }, 47 + { label: "Report Issues", href: "https://tangled.org/sans-self.org/opake.app/issues" }, 48 + { 49 + label: "Contributing", 50 + href: "https://tangled.org/sans-self.org/opake.app/tree/main/CONTRIBUTING.md", 51 + }, 52 + ], 53 + }, 54 + ]; 55 + 56 + function NavLink({ 57 + label, 58 + href, 59 + internal, 60 + }: { 61 + readonly label: string; 62 + readonly href: string; 63 + readonly internal?: boolean; 64 + }) { 65 + const className = "text-text-muted hover:text-secondary transition-colors"; 66 + 67 + if (internal) { 68 + return ( 69 + <Link to={href} className={className}> 70 + {label} 71 + </Link> 72 + ); 73 + } 74 + 75 + if (href.startsWith("#")) { 76 + const scrollToSection = (event: React.MouseEvent) => { 77 + event.preventDefault(); 78 + const target = document.querySelector(href); 79 + if (target) { 80 + const navHeight = 72; 81 + const top = target.getBoundingClientRect().top + window.scrollY - navHeight; 82 + window.scrollTo({ top, behavior: "smooth" }); 83 + } 84 + }; 85 + 86 + return ( 87 + <a href={href} onClick={scrollToSection} className={className}> 88 + {label} 89 + </a> 90 + ); 91 + } 92 + 93 + return ( 94 + <a href={href} target="_blank" rel="noopener noreferrer" className={className}> 95 + {label} 96 + </a> 97 + ); 98 + } 99 + 100 + function PublicLayout() { 101 + return ( 102 + <div className="bg-base-300 flex min-h-screen flex-col font-sans"> 103 + {/* Nav — transparent, no border, matching screenshot */} 104 + <nav className="border-border-accent/30 bg-base-300/80 fixed inset-x-0 top-0 z-50 flex items-center justify-between border-b px-8 py-4 backdrop-blur-[14px] sm:px-12"> 105 + <Link to="/"> 106 + <OpakeLogo /> 107 + </Link> 108 + 109 + <div className="text-ui hidden items-center gap-7 md:flex"> 110 + {NAV_LINKS.map((link) => ( 111 + <NavLink 112 + key={link.label} 113 + label={link.label} 114 + href={link.href} 115 + internal={link.internal} 116 + /> 117 + ))} 118 + </div> 119 + 120 + <Link to="/cabinet" className="btn btn-neutral btn-sm text-ui gap-2"> 121 + Open your cabinet 122 + <ArrowRightIcon size={14} /> 123 + </Link> 124 + </nav> 125 + 126 + {/* Page content */} 127 + <main className="flex-1"> 128 + <Outlet /> 129 + </main> 130 + 131 + {/* Footer */} 132 + <footer className="border-border-accent/30 border-t"> 133 + <div className="mx-auto max-w-5xl px-8 pt-20 pb-10 sm:px-12"> 134 + {/* Top row — logo + link columns */} 135 + <div className="grid grid-cols-2 gap-12 sm:grid-cols-4"> 136 + {/* Brand column */} 137 + <div className="col-span-2 sm:col-span-1"> 138 + <OpakeLogo /> 139 + <p className="text-text-muted mt-4 max-w-50 text-[0.78rem] leading-[1.75]"> 140 + Encrypted by design. 141 + <br /> 142 + Built with the AT&nbsp;Protocol. 143 + </p> 144 + <p className="text-text-faint text-caption mt-3 tracking-[0.04em]"> 145 + Amsterdam · The Open Web 146 + </p> 147 + </div> 148 + 149 + {/* Link columns */} 150 + {FOOTER_GROUPS.map((group) => ( 151 + <div key={group.heading}> 152 + <h4 className="text-label text-text-faint mb-4 tracking-widest uppercase"> 153 + {group.heading} 154 + </h4> 155 + <ul className="flex flex-col gap-2.5"> 156 + {group.links.map((link) => ( 157 + <li key={link.label}> 158 + {link.internal ? ( 159 + <Link 160 + to={link.href} 161 + className="text-text-muted hover:text-base-content text-[0.8rem] transition-colors" 162 + > 163 + {link.label} 164 + </Link> 165 + ) : ( 166 + <a 167 + href={link.href} 168 + target="_blank" 169 + rel="noopener noreferrer" 170 + className="text-text-muted hover:text-base-content text-[0.8rem] transition-colors" 171 + > 172 + {link.label} 173 + </a> 174 + )} 175 + </li> 176 + ))} 177 + </ul> 178 + </div> 179 + ))} 180 + </div> 181 + 182 + {/* Bottom bar */} 183 + <div className="border-border-accent/30 mt-16 flex flex-wrap items-center justify-between gap-4 border-t pt-6"> 184 + <p className="text-text-faint text-caption tracking-wider"> 185 + MMXXVI · Opake · All rights reserved 186 + </p> 187 + <p className="font-display text-text-faint text-caption tracking-wide italic"> 188 + Privacy without the bunker. 189 + </p> 190 + </div> 191 + </div> 192 + </footer> 193 + </div> 194 + ); 195 + } 196 + 197 + export const Route = createFileRoute("/_public")({ 198 + component: PublicLayout, 199 + });
+58
web/src/routes/_public/docs/$slug.tsx
··· 1 + import type { ComponentType } from "react"; 2 + import { createFileRoute } from "@tanstack/react-router"; 3 + import { MdxContent } from "@/components/content/MdxProvider"; 4 + import { findDoc } from "@/lib/docs-registry"; 5 + 6 + import GettingStarted from "@/content/docs/getting-started.mdx"; 7 + import AtProtocol from "@/content/docs/at-protocol.mdx"; 8 + import EncryptionKeys from "@/content/docs/encryption-keys.mdx"; 9 + import SharingDids from "@/content/docs/sharing-dids.mdx"; 10 + import Keyrings from "@/content/docs/keyrings.mdx"; 11 + import Pairing from "@/content/docs/pairing.mdx"; 12 + import Glossary from "@/content/docs/glossary.mdx"; 13 + import Faq from "@/content/faq.mdx"; 14 + 15 + const CONTENT_BY_SLUG: Partial< 16 + Record<string, ComponentType<{ readonly components?: Record<string, ComponentType<never>> }>> 17 + > = { 18 + "getting-started": GettingStarted, 19 + "at-protocol": AtProtocol, 20 + "encryption-keys": EncryptionKeys, 21 + "sharing-dids": SharingDids, 22 + keyrings: Keyrings, 23 + pairing: Pairing, 24 + glossary: Glossary, 25 + faq: Faq, 26 + }; 27 + 28 + function DocChapterPage() { 29 + const { slug } = Route.useParams(); 30 + const Content = CONTENT_BY_SLUG[slug]; 31 + 32 + if (!Content) { 33 + return ( 34 + <div className="flex flex-col items-center gap-4 px-6 pt-28 pb-20"> 35 + <p className="text-text-muted">Page not found.</p> 36 + </div> 37 + ); 38 + } 39 + 40 + return ( 41 + <div className="mx-auto max-w-3xl px-6 pt-28 pb-20 sm:px-10"> 42 + <MdxContent Content={Content} className="prose" /> 43 + </div> 44 + ); 45 + } 46 + 47 + export const Route = createFileRoute("/_public/docs/$slug")({ 48 + head: ({ params }) => { 49 + const doc = findDoc(params.slug); 50 + return { 51 + meta: [ 52 + { title: doc ? `${doc.title} — Opake` : "Docs — Opake" }, 53 + { name: "description", content: doc?.description ?? "" }, 54 + ], 55 + }; 56 + }, 57 + component: DocChapterPage, 58 + });
+24
web/src/routes/_public/docs/index.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { MdxContent } from "@/components/content/MdxProvider"; 3 + import DocsIndexContent from "@/content/docs/index.mdx"; 4 + 5 + function DocsIndexPage() { 6 + return ( 7 + <div className="px-6 pt-28 pb-20 sm:px-10"> 8 + <MdxContent Content={DocsIndexContent} /> 9 + </div> 10 + ); 11 + } 12 + 13 + export const Route = createFileRoute("/_public/docs/")({ 14 + head: () => ({ 15 + meta: [ 16 + { title: "The Opaque Handbook — Opake" }, 17 + { 18 + name: "description", 19 + content: "Everything you need to get the most out of Opake.", 20 + }, 21 + ], 22 + }), 23 + component: DocsIndexPage, 24 + });
+21
web/src/routes/_public/faq.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { MdxContent } from "@/components/content/MdxProvider"; 3 + import FaqContent from "@/content/faq.mdx"; 4 + 5 + function FaqPage() { 6 + return ( 7 + <div className="px-6 pt-16 pb-20 sm:px-10"> 8 + <MdxContent Content={FaqContent} /> 9 + </div> 10 + ); 11 + } 12 + 13 + export const Route = createFileRoute("/_public/faq")({ 14 + head: () => ({ 15 + meta: [ 16 + { title: "FAQ — Opake" }, 17 + { name: "description", content: "Frequently asked questions about Opake." }, 18 + ], 19 + }), 20 + component: FaqPage, 21 + });
+164
web/src/routes/_public/index.tsx
··· 1 + import { createFileRoute, Link } from "@tanstack/react-router"; 2 + import { ArrowRightIcon } from "@phosphor-icons/react"; 3 + import { 4 + HeroSection, 5 + HeroHeadline, 6 + Highlight, 7 + HeroSubtext, 8 + CtaGroup, 9 + PrimaryCta, 10 + SecondaryCta, 11 + Divider, 12 + SectionHeader, 13 + InfoGrid, 14 + InfoCard, 15 + StepGrid, 16 + StepCard, 17 + StepTitle, 18 + StepBody, 19 + CenterAction, 20 + TextLink, 21 + Section, 22 + } from "@/components/content/landing"; 23 + import { CabinetMockup } from "@/components/CabinetMockup"; 24 + 25 + const DESCRIPTION = 26 + "Encrypted personal cloud built on the AT Protocol. Your files — encrypted, owned, shared on your terms."; 27 + 28 + function LandingPage() { 29 + return ( 30 + <> 31 + <HeroSection> 32 + <HeroHeadline> 33 + Your data; <Highlight>freely shared</Highlight>, privately kept. 34 + </HeroHeadline> 35 + 36 + <HeroSubtext> 37 + Private collaboration, finally made simple. Your files belong to you, they&apos;re shared 38 + on your terms, and no one else has the keys. 39 + </HeroSubtext> 40 + 41 + <CtaGroup> 42 + <PrimaryCta href="/devices/login"> 43 + Open your cabinet <ArrowRightIcon size="1em" /> 44 + </PrimaryCta> 45 + <SecondaryCta href="/docs">Learn more</SecondaryCta> 46 + </CtaGroup> 47 + 48 + <div className="mt-18 w-full max-w-3xl"> 49 + <CabinetMockup /> 50 + </div> 51 + </HeroSection> 52 + 53 + <Section> 54 + <Divider text="What is Opake?" /> 55 + <InfoGrid 56 + description={ 57 + <> 58 + <SectionHeader> 59 + An encrypted cloud that answers to <Highlight>you</Highlight>, not a big tech 60 + company. 61 + </SectionHeader> 62 + <Divider text="Total Privacy" /> 63 + Your data is encrypted locally. By the time it hits a server, it&apos;s unreadable to 64 + everyone but you. We couldn't look at your files even if we wanted to. 65 + <Divider text="Modern Sharing" /> 66 + Forget the "Create an Account" hurdles. Share instantly using your digital handle. 67 + Your files, your rules—grant or revoke access whenever you want. 68 + <div className="border-border-accent/40 my-6 border-t" /> 69 + <TextLink href="/docs/at-protocol"> 70 + Read the technical documentation <ArrowRightIcon size="1em" /> 71 + </TextLink> 72 + </> 73 + } 74 + > 75 + <InfoCard icon="lock" title="End-to-end encrypted"> 76 + Your files are encrypted before they leave your device. Only you hold the 77 + keys&nbsp;&mdash; always. 78 + </InfoCard> 79 + 80 + <InfoCard icon="network" title="No Platform Lock-in"> 81 + Built on the same open standard as Bluesky. Your identity and your files belong to you, 82 + not us. 83 + </InfoCard> 84 + 85 + <InfoCard icon="share" title="Seamless sharing"> 86 + Skip the "Create an Account" forms. Share instantly using your digital handle and stay 87 + in control of who sees what 88 + </InfoCard> 89 + 90 + <InfoCard icon="sparkles" title="We Can’t Peek"> 91 + Our system is physically unable to read your data. Your privacy is built into the code, 92 + not just a promise in a legal document. 93 + </InfoCard> 94 + </InfoGrid> 95 + </Section> 96 + <Section id="how-it-works" surface="raised"> 97 + <Divider text="How it works" /> 98 + <SectionHeader> 99 + Simple for you. <Highlight>Invisible to everyone else</Highlight>. 100 + </SectionHeader> 101 + 102 + <StepGrid> 103 + <StepCard num="I" icon="lock" featured> 104 + <StepTitle>Start with your handle</StepTitle> 105 + <StepBody> 106 + Log in using your AT Protocol identity (like Bluesky). Opake connects to your digital 107 + home and sets up your private keys automatically. 108 + </StepBody> 109 + </StepCard> 110 + 111 + <StepCard num="II" icon="lock"> 112 + <StepTitle>Automatic Privacy</StepTitle> 113 + <StepBody> 114 + Drop a file in. It’s locked on your device before it’s ever uploaded. To the rest of 115 + the world—including your storage provider—it’s completely unreadable. 116 + </StepBody> 117 + </StepCard> 118 + 119 + <StepCard num="III" icon="share"> 120 + <StepTitle>Effortless Sharing</StepTitle> 121 + <StepBody> 122 + Just type a friend’s handle to share. We handle the complex security in the background 123 + so that only the person you chose can open what you’ve sent. 124 + </StepBody> 125 + </StepCard> 126 + 127 + <StepCard num="IV" icon="globe"> 128 + <StepTitle>Never Locked In</StepTitle> 129 + <StepBody> 130 + You’re in charge of where your files live. Switch providers or host them yourself 131 + whenever you like. Your data and your identity always stay with you 132 + </StepBody> 133 + </StepCard> 134 + </StepGrid> 135 + </Section> 136 + 137 + <CenterAction 138 + headline="your cabinet is waiting." 139 + subtext="Take back your data. No surveillance, no compromise. Just your files, exactly as private as you choose." 140 + > 141 + <Link 142 + to="/devices/login" 143 + className="bg-base-100 text-base-content hover:bg-accent inline-flex items-center gap-2.5 rounded-lg px-7 py-3.5 text-sm font-medium transition-colors" 144 + > 145 + Open your cabinet <ArrowRightIcon size="1em" /> 146 + </Link> 147 + </CenterAction> 148 + </> 149 + ); 150 + } 151 + 152 + export const Route = createFileRoute("/_public/")({ 153 + head: () => ({ 154 + meta: [ 155 + { title: "Opake — Your data, freely shared, privately kept" }, 156 + { name: "description", content: DESCRIPTION }, 157 + { name: "og:title", content: "Opake — Your data, freely shared, privately kept" }, 158 + { name: "og:description", content: DESCRIPTION }, 159 + { name: "twitter:title", content: "Opake — Your data, freely shared, privately kept" }, 160 + { name: "twitter:description", content: DESCRIPTION }, 161 + ], 162 + }), 163 + component: LandingPage, 164 + });
+24
web/src/routes/_public/troubleshooting.tsx
··· 1 + import { createFileRoute } from "@tanstack/react-router"; 2 + import { MdxContent } from "@/components/content/MdxProvider"; 3 + import TroubleshootingContent from "@/content/troubleshooting.mdx"; 4 + 5 + function TroubleshootingPage() { 6 + return ( 7 + <div className="mx-auto max-w-3xl px-6 pt-28 pb-20 sm:px-10"> 8 + <MdxContent Content={TroubleshootingContent} className="prose" /> 9 + </div> 10 + ); 11 + } 12 + 13 + export const Route = createFileRoute("/_public/troubleshooting")({ 14 + head: () => ({ 15 + meta: [ 16 + { title: "Troubleshooting — Opake" }, 17 + { 18 + name: "description", 19 + content: "Common issues and solutions for Opake.", 20 + }, 21 + ], 22 + }), 23 + component: TroubleshootingPage, 24 + });
-89
web/src/routes/cabinet/docs.tsx
··· 1 - import { createFileRoute } from "@tanstack/react-router"; 2 - import { 3 - SparkleIcon, 4 - LockIcon, 5 - ShareNetworkIcon, 6 - GraphIcon, 7 - QuestionIcon, 8 - ArrowSquareOutIcon, 9 - } from "@phosphor-icons/react"; 10 - import { PanelShell } from "@/components/cabinet/PanelShell"; 11 - 12 - const DOCS_SECTIONS = [ 13 - { 14 - id: "getting-started", 15 - title: "Getting Started", 16 - icon: SparkleIcon, 17 - desc: "Set up your cabinet, create your first encrypted file, and explore the interface.", 18 - }, 19 - { 20 - id: "encryption", 21 - title: "Encryption & Keys", 22 - icon: LockIcon, 23 - desc: "How end-to-end encryption works in Opake and how your keys are managed.", 24 - }, 25 - { 26 - id: "sharing", 27 - title: "Sharing & DIDs", 28 - icon: ShareNetworkIcon, 29 - desc: "Share files using decentralised identifiers without a central authority.", 30 - }, 31 - { 32 - id: "at-protocol", 33 - title: "AT Protocol", 34 - icon: GraphIcon, 35 - desc: "The open standard powering Opake — identity, data portability, and federation.", 36 - }, 37 - { 38 - id: "faq", 39 - title: "FAQ", 40 - icon: QuestionIcon, 41 - desc: "Common questions about privacy, security, and how Opake compares to alternatives.", 42 - }, 43 - ]; 44 - 45 - function DocsPage() { 46 - const breadcrumbs = ( 47 - <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 48 - <ul> 49 - <li> 50 - <span className="text-base-content font-medium">Docs & Help</span> 51 - </li> 52 - </ul> 53 - </div> 54 - ); 55 - 56 - return ( 57 - <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Documentation · Opake"> 58 - <div className="p-5"> 59 - <div className="mb-5"> 60 - <div className="text-ui text-base-content mb-1 font-medium">Documentation</div> 61 - <div className="text-text-muted text-xs"> 62 - Everything you need to get the most out of Opake. 63 - </div> 64 - </div> 65 - <div className="flex flex-col gap-2"> 66 - {DOCS_SECTIONS.map((s) => ( 67 - <div 68 - key={s.id} 69 - className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 70 - > 71 - <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 72 - <s.icon size={14} className="text-primary" /> 73 - </div> 74 - <div className="flex-1"> 75 - <div className="text-ui text-base-content mb-0.5 font-medium">{s.title}</div> 76 - <div className="text-caption text-text-muted leading-relaxed">{s.desc}</div> 77 - </div> 78 - <ArrowSquareOutIcon size={12} className="text-text-faint mt-0.5 shrink-0" /> 79 - </div> 80 - ))} 81 - </div> 82 - </div> 83 - </PanelShell> 84 - ); 85 - } 86 - 87 - export const Route = createFileRoute("/cabinet/docs")({ 88 - component: DocsPage, 89 - });
+85
web/src/routes/cabinet/docs/$slug.tsx
··· 1 + import type { ComponentType } from "react"; 2 + import { createFileRoute, Link } from "@tanstack/react-router"; 3 + import { ArrowLeftIcon } from "@phosphor-icons/react"; 4 + import { PanelShell } from "@/components/cabinet/PanelShell"; 5 + import { MdxContent } from "@/components/content/MdxProvider"; 6 + import { findDoc } from "@/lib/docs-registry"; 7 + 8 + import GettingStarted from "@/content/docs/getting-started.mdx"; 9 + import AtProtocol from "@/content/docs/at-protocol.mdx"; 10 + import EncryptionKeys from "@/content/docs/encryption-keys.mdx"; 11 + import SharingDids from "@/content/docs/sharing-dids.mdx"; 12 + import Keyrings from "@/content/docs/keyrings.mdx"; 13 + import Pairing from "@/content/docs/pairing.mdx"; 14 + import Glossary from "@/content/docs/glossary.mdx"; 15 + import Faq from "@/content/faq.mdx"; 16 + 17 + type MdxComponent = ComponentType<{ 18 + readonly components?: Record<string, ComponentType<never>>; 19 + }>; 20 + 21 + const CONTENT_BY_SLUG: Partial<Record<string, MdxComponent>> = { 22 + "getting-started": GettingStarted, 23 + "at-protocol": AtProtocol, 24 + "encryption-keys": EncryptionKeys, 25 + "sharing-dids": SharingDids, 26 + keyrings: Keyrings, 27 + pairing: Pairing, 28 + glossary: Glossary, 29 + faq: Faq, 30 + }; 31 + 32 + function DocChapterPage() { 33 + const { slug } = Route.useParams(); 34 + const meta = findDoc(slug); 35 + const Content = CONTENT_BY_SLUG[slug]; 36 + 37 + if (!meta || !Content) { 38 + return ( 39 + <PanelShell depth={1} breadcrumbs={<span />} footer="Documentation · Opake"> 40 + <div className="flex flex-col items-center gap-4 p-10"> 41 + <p className="text-text-muted">Page not found.</p> 42 + <Link to="/cabinet/docs" className="btn btn-neutral btn-sm"> 43 + Back to docs 44 + </Link> 45 + </div> 46 + </PanelShell> 47 + ); 48 + } 49 + 50 + const breadcrumbs = ( 51 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 52 + <ul> 53 + <li> 54 + <Link to="/cabinet/docs" className="text-text-muted hover:text-base-content"> 55 + Docs & Help 56 + </Link> 57 + </li> 58 + <li> 59 + <span className="text-base-content font-medium">{meta.title}</span> 60 + </li> 61 + </ul> 62 + </div> 63 + ); 64 + 65 + return ( 66 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer={`${meta.title} · Opake`}> 67 + <div className="overflow-y-auto p-6"> 68 + <MdxContent Content={Content} className="prose max-w-none text-sm leading-relaxed" /> 69 + <div className="border-border-accent/30 mt-10 border-t pt-4"> 70 + <Link 71 + to="/cabinet/docs" 72 + className="text-text-muted hover:text-primary text-ui inline-flex items-center gap-1.5 transition-colors" 73 + > 74 + <ArrowLeftIcon size={12} /> 75 + Back to docs 76 + </Link> 77 + </div> 78 + </div> 79 + </PanelShell> 80 + ); 81 + } 82 + 83 + export const Route = createFileRoute("/cabinet/docs/$slug")({ 84 + component: DocChapterPage, 85 + });
+54
web/src/routes/cabinet/docs/index.tsx
··· 1 + import { createElement } from "react"; 2 + import { createFileRoute, Link } from "@tanstack/react-router"; 3 + import { ArrowSquareOutIcon } from "@phosphor-icons/react"; 4 + import { PanelShell } from "@/components/cabinet/PanelShell"; 5 + import { resolveIcon } from "@/components/content/icons"; 6 + import { DOCS_REGISTRY } from "@/lib/docs-registry"; 7 + 8 + function DocsIndexPage() { 9 + const breadcrumbs = ( 10 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 11 + <ul> 12 + <li> 13 + <span className="text-base-content font-medium">Docs & Help</span> 14 + </li> 15 + </ul> 16 + </div> 17 + ); 18 + 19 + return ( 20 + <PanelShell depth={1} breadcrumbs={breadcrumbs} footer="Documentation · Opake"> 21 + <div className="p-5"> 22 + <div className="mb-5"> 23 + <div className="text-ui text-base-content mb-1 font-medium">Documentation</div> 24 + <div className="text-text-muted text-xs"> 25 + Everything you need to get the most out of Opake. 26 + </div> 27 + </div> 28 + <div className="flex flex-col gap-2"> 29 + {DOCS_REGISTRY.map((s) => ( 30 + <Link 31 + key={s.slug} 32 + to="/cabinet/docs/$slug" 33 + params={{ slug: s.slug }} 34 + className="card card-bordered border-base-300/50 bg-base-100 hover:shadow-panel-sm cursor-pointer p-3.5 transition-shadow" 35 + > 36 + <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 37 + {createElement(resolveIcon(s.icon), { size: 14, className: "text-primary" })} 38 + </div> 39 + <div className="flex-1"> 40 + <div className="text-ui text-base-content mb-0.5 font-medium">{s.title}</div> 41 + <div className="text-caption text-text-muted leading-relaxed">{s.description}</div> 42 + </div> 43 + <ArrowSquareOutIcon size={12} className="text-text-faint mt-0.5 shrink-0" /> 44 + </Link> 45 + ))} 46 + </div> 47 + </div> 48 + </PanelShell> 49 + ); 50 + } 51 + 52 + export const Route = createFileRoute("/cabinet/docs/")({ 53 + component: DocsIndexPage, 54 + });
+9
web/src/routes/cabinet/docs/route.tsx
··· 1 + import { createFileRoute, Outlet } from "@tanstack/react-router"; 2 + 3 + function DocsLayout() { 4 + return <Outlet />; 5 + } 6 + 7 + export const Route = createFileRoute("/cabinet/docs")({ 8 + component: DocsLayout, 9 + });
-70
web/src/routes/index.tsx
··· 1 - import { createFileRoute, Link } from "@tanstack/react-router"; 2 - import { ArrowRightIcon } from "@phosphor-icons/react"; 3 - import { OpakeLogo } from "@/components/OpakeLogo"; 4 - import LandingContent from "@/content/landing.mdx"; 5 - 6 - function LandingPage() { 7 - return ( 8 - <div className="bg-base-300 flex min-h-screen flex-col font-sans"> 9 - {/* Nav */} 10 - <nav className="border-base-300/50 bg-base-300/85 fixed inset-x-0 top-0 z-50 flex items-center justify-between border-b px-10 py-3.5 backdrop-blur-[14px]"> 11 - <OpakeLogo /> 12 - <Link to="/cabinet" className="btn btn-neutral btn-sm text-ui gap-2"> 13 - Open the Cabinet 14 - <ArrowRightIcon size={14} /> 15 - </Link> 16 - </nav> 17 - 18 - {/* Hero */} 19 - <section className="flex min-h-screen flex-col items-center justify-center px-10 pt-30 pb-20"> 20 - {/* Ornamental rule */} 21 - <div className="divider text-caption text-primary before:bg-border-accent after:bg-border-accent mb-8 w-80 self-center tracking-[0.18em] uppercase"> 22 - Built on the AT Protocol 23 - </div> 24 - 25 - <h1 className="font-display text-base-content mb-7 max-w-205 text-center text-[clamp(3.4rem,7.5vw,6.2rem)] leading-[1.04] font-normal tracking-tight"> 26 - Your data, <em className="text-primary">freely shared</em>, 27 - <br /> 28 - privately kept. 29 - </h1> 30 - 31 - <div className="prose text-secondary mb-10 max-w-130 text-center text-[1.05rem] leading-[1.75]"> 32 - <LandingContent /> 33 - </div> 34 - 35 - <div className="flex items-center gap-3.5"> 36 - <Link 37 - to="/cabinet" 38 - className="btn btn-neutral gap-2.5 shadow-[0_4px_20px_oklch(0.155_0.035_70/0.18)]" 39 - > 40 - Open the Cabinet 41 - <ArrowRightIcon size={15} /> 42 - </Link> 43 - <a 44 - href="#about" 45 - className="btn btn-outline border-border-accent text-secondary hover:bg-accent" 46 - > 47 - Learn more 48 - </a> 49 - </div> 50 - </section> 51 - </div> 52 - ); 53 - } 54 - 55 - const DESCRIPTION = 56 - "Encrypted personal cloud built on the AT Protocol. Your files — encrypted, owned, shared on your terms."; 57 - 58 - export const Route = createFileRoute("/")({ 59 - head: () => ({ 60 - meta: [ 61 - { title: "Opake — Your data, freely shared, privately kept" }, 62 - { name: "description", content: DESCRIPTION }, 63 - { name: "og:title", content: "Opake — Your data, freely shared, privately kept" }, 64 - { name: "og:description", content: DESCRIPTION }, 65 - { name: "twitter:title", content: "Opake — Your data, freely shared, privately kept" }, 66 - { name: "twitter:description", content: DESCRIPTION }, 67 - ], 68 - }), 69 - component: LandingPage, 70 - });
+6
web/src/stores/auth.ts
··· 1 1 // Auth store — OAuth 2.0 + DPoP + identity resolution via Zustand. 2 2 // 3 + // NOTE TO EDITORS: 4 + // Opake uses a dual-documentation system. If you modify the authentication 5 + // flow, identity state machine, or session persistence in this file, you 6 + // MUST also update the corresponding MDX content in `web/src/content/` 7 + // to prevent documentation drift. 8 + // 3 9 // Two independent state dimensions: 4 10 // Session: none | authenticating | active 5 11 // Identity: unchecked | checking | fresh | remote_only | conflict | ready
+1 -1
web/src/stores/documents/store.ts
··· 64 64 readonly moveEntry: (entryUri: string, targetDirectoryUri: string | null) => Promise<void>; 65 65 readonly renameDirectory: (directoryUri: string, newName: string) => Promise<void>; 66 66 readonly ancestorsOf: (directoryUri: string | null) => readonly DirectoryAncestor[]; 67 - /** Build the cabinet files route splat path for a document URI, or null if not in the tree. */ 67 + /** Build your cabinet files route splat path for a document URI, or null if not in the tree. */ 68 68 readonly cabinetPathFor: (documentUri: string) => string | null; 69 69 } 70 70