Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place

frontend work to fit better without scrolling

+787 -954
+9 -5
agents.md
··· 103 103 104 104 Bun workspaces: `packages/@wisp/*`, `apps/main-app`, `apps/hosting-service` 105 105 106 - There are two typescript apps 106 + There are three typescript apps 107 107 **`apps/main-app`** - Main backend (Bun + Elysia) 108 108 109 109 - OAuth authentication and session management ··· 112 112 - Admin database view in /admin 113 113 - React frontend in public/ 114 114 115 - **`apps/hosting-service`** - CDN static file server (Node + Hono) 116 - 117 - - Watches AT Protocol firehose for `place.wisp.fs` record changes 118 - - Downloads and caches site files to disk 115 + **`apps/hosting-service`** - CDN static file server (Bun + Hono) 119 116 - Serves sites at `https://sites.wisp.place/{did}/{site-name}` and custom domains 120 117 - Handles redirects (`_redirects` file support) and routing logic 118 + - Pulls sites from Tiered Storage (packages/@wispplace/tiered-storage) 119 + 120 + **`apps/firehose-service`** - ATProto Firehose consumer (Bun or Node) 121 + - Watches AT Protocol firehose for `place.wisp.*` record changes 122 + - Downloads and caches site files to S3 121 123 - Backfill mode for syncing existing sites 124 + 122 125 123 126 ### Shared Packages (`packages/@wisp/*`) 124 127 ··· 130 133 - **`constants`** - Shared constants (limits, file patterns, default settings) 131 134 - **`observability`** - OpenTelemetry instrumentation 132 135 - **`safe-fetch`** - Wrapped fetch with timeout/retry logic 136 + - **`tiered-storage`** - KV caching where reads bubble up from cold tier to warm/hot tier and writes bubble down from selected tier down. Streaming as well as buffering support. Used to store files in S3 cold tier as source of truth 133 137 134 138 ### CLI 135 139
-2
apps/hosting-service/src/index.ts
··· 79 79 S3 Region: ${storageConfig.s3Region} 80 80 S3 Endpoint: ${storageConfig.s3Endpoint} 81 81 S3 Prefix: ${storageConfig.s3Prefix} 82 - 83 - Firehose: DISABLED (read-only) 84 82 `); 85 83 86 84 // Graceful shutdown
+3 -3
apps/main-app/public/editor/editor.tsx
··· 483 483 </TabsContent> 484 484 485 485 {/* Domains Tab */} 486 - <TabsContent value="domains" className="flex-1 m-0 mt-4 overflow-y-auto border border-border/30 bg-card/50 p-4 data-[state=inactive]:hidden"> 486 + <TabsContent value="domains" className="flex-1 m-0 mt-4 overflow-hidden data-[state=inactive]:hidden"> 487 487 <DomainsTab 488 488 wispDomains={wispDomains} 489 489 customDomains={customDomains} ··· 500 500 </TabsContent> 501 501 502 502 {/* Upload Tab */} 503 - <TabsContent value="upload" className="flex-1 m-0 mt-4 overflow-y-auto border border-border/30 bg-card/50 p-4 data-[state=inactive]:hidden"> 503 + <TabsContent value="upload" className="flex-1 m-0 mt-4 overflow-hidden data-[state=inactive]:hidden"> 504 504 <UploadTab 505 505 sites={sites} 506 506 sitesLoading={sitesLoading} ··· 509 509 </TabsContent> 510 510 511 511 {/* CLI Tab */} 512 - <TabsContent value="cli" className="flex-1 m-0 mt-4 overflow-y-auto border border-border/30 bg-card/50 p-4 data-[state=inactive]:hidden"> 512 + <TabsContent value="cli" className="flex-1 m-0 mt-4 overflow-hidden data-[state=inactive]:hidden"> 513 513 <CLITab /> 514 514 </TabsContent> 515 515 </Tabs>
+168 -224
apps/main-app/public/editor/tabs/CLITab.tsx
··· 1 - import { 2 - Card, 3 - CardContent, 4 - CardDescription, 5 - CardHeader, 6 - CardTitle, 7 - } from "@public/components/ui/card"; 8 1 import { Badge } from "@public/components/ui/badge"; 9 2 import { Download, ExternalLink } from "lucide-react"; 10 3 import { CodeBlock } from "@public/components/ui/code-block"; ··· 16 9 { 17 10 platform: "macOS (Apple Silicon)", 18 11 filename: "wisp-cli-aarch64-darwin", 19 - sha256: 20 - "06544b3a3e27a4b8d7b3a46a39fb7205cf90b3061e19fe533b090facd604f375", 12 + sha256: "06544b3a3e27a4b8d7b3a46a39fb7205cf90b3061e19fe533b090facd604f375", 21 13 }, 22 14 { 23 15 platform: "macOS (Intel)", 24 16 filename: "wisp-cli-x86_64-darwin", 25 - sha256: 26 - "9ec523e3ceef927b37adc52d449dcd9e13ea84fa49b0b77f0d5932c94cfe262e", 17 + sha256: "9ec523e3ceef927b37adc52d449dcd9e13ea84fa49b0b77f0d5932c94cfe262e", 27 18 }, 28 19 { 29 20 platform: "Linux (ARM64)", 30 21 filename: "wisp-cli-aarch64-linux", 31 - sha256: 32 - "42a262668e13dce36173a4096cdc2b22358b805cf192335f84534c7f695d395b", 22 + sha256: "42a262668e13dce36173a4096cdc2b22358b805cf192335f84534c7f695d395b", 33 23 }, 34 24 { 35 25 platform: "Linux (x86_64)", 36 26 filename: "wisp-cli-x86_64-linux", 37 - sha256: 38 - "589ee59f3959ddfbc12fea38d2bcb91701f1362f560ae6fd506bebea3150e2cc", 39 - }, 40 - ] as const; 41 - 42 - const FEATURES = [ 43 - { label: "Deploy", desc: "Push static sites directly from your terminal" }, 44 - { 45 - label: "Pull", 46 - desc: "Download sites from the PDS for development or backup", 47 - }, 48 - { 49 - label: "Serve", 50 - desc: "Run a local server with real-time firehose updates", 51 - }, 52 - { 53 - label: "Domains", 54 - desc: "Claim, manage, and assign custom domains on wisp.place", 27 + sha256: "589ee59f3959ddfbc12fea38d2bcb91701f1362f560ae6fd506bebea3150e2cc", 55 28 }, 56 29 ] as const; 57 30 58 31 const LINKS = [ 59 - { label: "CLI Documentation", href: "https://docs.wisp.place/cli" }, 32 + { label: "Docs", href: "https://docs.wisp.place/cli" }, 60 33 { 61 - label: "Source Code", 34 + label: "Source", 62 35 href: "https://tangled.org/@nekomimi.pet/wisp.place-monorepo/tree/main/cli", 63 36 }, 64 - { label: "Tangled Spindle CI/CD", href: "https://blog.tangled.org/ci" }, 37 + { label: "Spindle CI/CD", href: "https://blog.tangled.org/ci" }, 65 38 ] as const; 66 39 67 - export function CLITab() { 40 + function SectionLabel({ children }: { children: React.ReactNode }) { 68 41 return ( 69 - <div className="space-y-4 min-h-[400px]"> 70 - {/* Header + Features */} 71 - <Card> 72 - <CardHeader> 73 - <div className="flex items-center gap-2"> 74 - <CardTitle>Wisp CLI</CardTitle> 75 - <Badge variant="secondary" className="text-xs"> 76 - v1.0.0 77 - </Badge> 78 - </div> 79 - <CardDescription> 80 - Deploy static sites directly from your terminal 81 - </CardDescription> 82 - </CardHeader> 83 - <CardContent> 84 - <ul className="space-y-2"> 85 - {FEATURES.map(({ label, desc }) => ( 86 - <li key={label} className="flex items-start gap-3 text-sm"> 87 - <span className="text-muted-foreground mt-0.5 shrink-0 select-none"> 88 - &gt; 89 - </span> 90 - <span className="text-muted-foreground"> 91 - <strong className="text-foreground">{label}</strong> — {desc} 92 - </span> 93 - </li> 94 - ))} 95 - </ul> 96 - </CardContent> 97 - </Card> 42 + <p className="text-xs uppercase tracking-wider text-muted-foreground pb-2 mb-3 border-b border-border/50"> 43 + {children} 44 + </p> 45 + ); 46 + } 98 47 99 - {/* Downloads */} 100 - <Card> 101 - <CardHeader> 102 - <CardTitle className="text-base">Download v1.0.0</CardTitle> 103 - </CardHeader> 104 - <CardContent className="space-y-1.5"> 105 - {BINARIES.map(({ platform, filename, sha256 }) => ( 48 + export function CLITab() { 49 + return ( 50 + <div className="h-full flex flex-col border border-border/30 bg-card/50 font-mono"> 51 + {/* Header */} 52 + <div className="px-4 py-3 border-b border-border/50 flex-shrink-0 flex items-center justify-between gap-4 bg-card"> 53 + <div className="flex items-center gap-2"> 54 + <span className="text-sm font-semibold">Wisp CLI</span> 55 + <Badge variant="secondary" className="text-xs">v1.0.0</Badge> 56 + </div> 57 + <div className="flex items-center gap-4"> 58 + {LINKS.map(({ label, href }) => ( 106 59 <a 107 - key={filename} 108 - href={`${BASE_URL}/${filename}`} 109 - download 110 - className="flex flex-col gap-1 p-3 rounded-lg border border-border bg-muted/30 hover:bg-muted hover:border-muted-foreground/30 transition-colors group" 60 + key={href} 61 + href={href} 62 + target="_blank" 63 + rel="noopener noreferrer" 64 + className="flex items-center gap-1 text-xs text-accent hover:text-accent/80 transition-colors" 111 65 > 112 - <div className="flex items-center justify-between"> 113 - <span className="text-sm font-medium">{platform}</span> 114 - <Download className="w-4 h-4 text-muted-foreground group-hover:text-foreground transition-colors" /> 115 - </div> 116 - <span className="font-mono text-[11px] text-muted-foreground break-all leading-relaxed"> 117 - SHA-256: {sha256} 118 - </span> 66 + {label} 67 + <ExternalLink className="w-3 h-3" /> 119 68 </a> 120 69 ))} 121 - </CardContent> 122 - </Card> 70 + </div> 71 + </div> 123 72 124 - {/* Basic Usage */} 125 - <Card> 126 - <CardHeader> 127 - <CardTitle className="text-base">Basic Usage</CardTitle> 128 - </CardHeader> 129 - <CardContent className="space-y-5"> 73 + {/* Scrollable content */} 74 + <div className="flex-1 min-h-0 overflow-y-auto"> 75 + 76 + {/* Quick install */} 77 + <div className="p-4 border-b border-border/50"> 78 + <SectionLabel>Install</SectionLabel> 130 79 <div className="space-y-2"> 131 - <p className="text-sm font-medium">Deploy a Site</p> 132 - <CodeBlock 133 - code={`./wisp-cli deploy your-handle.bsky.social \\ 134 - --path ./dist \\ 135 - --site my-site 136 - 137 - # Available at: 138 - # https://sites.wisp.place/your-handle/my-site`} 139 - language="bash" 140 - /> 80 + <div className="flex items-center gap-3 px-3 py-2.5 bg-muted/40 border border-border/60"> 81 + <span className="text-accent text-xs select-none shrink-0">$</span> 82 + <code className="text-sm flex-1">npm install -g wispctl</code> 83 + <span className="text-[10px] text-muted-foreground border border-border/50 px-1.5 py-0.5 shrink-0">recommended</span> 84 + </div> 85 + <div className="flex items-center gap-3 px-3 py-2.5 bg-muted/40 border border-border/60"> 86 + <span className="text-accent text-xs select-none shrink-0">$</span> 87 + <code className="text-sm flex-1">npm create wisp@latest</code> 88 + <span className="text-[10px] text-muted-foreground shrink-0">scaffold a project</span> 89 + </div> 141 90 </div> 91 + </div> 142 92 143 - <div className="space-y-2"> 144 - <p className="text-sm font-medium">Pull a Site</p> 145 - <CodeBlock 146 - code={`./wisp-cli pull your-handle.bsky.social \\ 147 - --site my-site \\ 148 - --output ./my-site`} 149 - language="bash" 150 - /> 93 + {/* Binary downloads */} 94 + <div className="p-4 border-b border-border/50"> 95 + <SectionLabel>Binary Downloads v1.0.0</SectionLabel> 96 + <div className="grid grid-cols-2 gap-2"> 97 + {BINARIES.map(({ platform, filename, sha256 }) => ( 98 + <a 99 + key={filename} 100 + href={`${BASE_URL}/${filename}`} 101 + download 102 + className="flex items-start justify-between gap-2 p-3 bg-card border border-border/60 hover:bg-muted/40 hover:border-border transition-colors group" 103 + > 104 + <div className="min-w-0"> 105 + <div className="text-xs font-medium leading-snug">{platform}</div> 106 + <div className="font-mono text-[10px] text-muted-foreground mt-1 truncate"> 107 + sha256: {sha256.slice(0, 12)}… 108 + </div> 109 + </div> 110 + <Download className="w-3.5 h-3.5 text-muted-foreground group-hover:text-accent transition-colors flex-shrink-0 mt-0.5" /> 111 + </a> 112 + ))} 151 113 </div> 114 + </div> 152 115 153 - <div className="space-y-2"> 154 - <p className="text-sm font-medium">Serve with Live Updates</p> 155 - <CodeBlock 156 - code={`# Serve on http://localhost:8080 (default) 157 - ./wisp-cli serve your-handle.bsky.social --site my-site 116 + {/* Commands */} 117 + <div className="p-4 space-y-2"> 118 + <SectionLabel>Commands</SectionLabel> 158 119 159 - # Custom port, SPA mode, or directory listing 120 + <details className="group border border-border/60 open:border-accent/40"> 121 + <summary className="flex items-center justify-between px-3 py-2.5 bg-muted/30 cursor-pointer hover:bg-muted/50 select-none list-none [&::-webkit-details-marker]:hidden transition-colors"> 122 + <span className="text-sm"> 123 + <span className="text-accent mr-2">$</span> 124 + deploy · pull · serve 125 + </span> 126 + <span className="text-accent font-medium text-sm leading-none group-open:hidden">+</span> 127 + <span className="text-accent font-medium text-sm leading-none hidden group-open:inline">−</span> 128 + </summary> 129 + <div className="border-t border-border/50 p-4 space-y-4 bg-background"> 130 + <div className="space-y-1.5"> 131 + <p className="text-xs text-muted-foreground font-medium">Deploy</p> 132 + <CodeBlock 133 + code={`./wisp-cli deploy your-handle.bsky.social \\ 134 + --path ./dist \\ 135 + --site my-site 136 + 137 + # https://sites.wisp.place/your-handle/my-site`} 138 + language="bash" 139 + /> 140 + </div> 141 + <div className="space-y-1.5"> 142 + <p className="text-xs text-muted-foreground font-medium">Pull</p> 143 + <CodeBlock 144 + code={`./wisp-cli pull your-handle.bsky.social \\ 145 + --site my-site --output ./my-site`} 146 + language="bash" 147 + /> 148 + </div> 149 + <div className="space-y-1.5"> 150 + <p className="text-xs text-muted-foreground font-medium">Serve with live updates</p> 151 + <CodeBlock 152 + code={`./wisp-cli serve your-handle.bsky.social --site my-site 160 153 ./wisp-cli serve your-handle.bsky.social --site my-site --port 3000 161 - ./wisp-cli serve your-handle.bsky.social --site my-site --spa 162 - ./wisp-cli serve your-handle.bsky.social --site my-site --directory`} 163 - language="bash" 164 - /> 165 - </div> 154 + ./wisp-cli serve your-handle.bsky.social --site my-site --spa`} 155 + language="bash" 156 + /> 157 + </div> 158 + </div> 159 + </details> 166 160 167 - <div className="space-y-2"> 168 - <p className="text-sm font-medium">Domain Management</p> 169 - <CodeBlock 170 - code={`./wisp-cli domain claim your-handle.bsky.social --domain example.com 161 + <details className="group border border-border/60 open:border-accent/40"> 162 + <summary className="flex items-center justify-between px-3 py-2.5 bg-muted/30 cursor-pointer hover:bg-muted/50 select-none list-none [&::-webkit-details-marker]:hidden transition-colors"> 163 + <span className="text-sm"> 164 + <span className="text-accent mr-2">$</span> 165 + domain · site management 166 + </span> 167 + <span className="text-accent font-medium text-sm leading-none group-open:hidden">+</span> 168 + <span className="text-accent font-medium text-sm leading-none hidden group-open:inline">−</span> 169 + </summary> 170 + <div className="border-t border-border/50 p-4 bg-background"> 171 + <CodeBlock 172 + code={`./wisp-cli domain claim your-handle.bsky.social --domain example.com 171 173 ./wisp-cli domain claim-subdomain your-handle.bsky.social --subdomain alice 172 174 ./wisp-cli domain status your-handle.bsky.social --domain example.com 173 175 ./wisp-cli domain add-site your-handle.bsky.social --domain example.com --site mysite 174 176 ./wisp-cli domain delete your-handle.bsky.social --domain example.com 175 - ./wisp-cli site delete your-handle.bsky.social --site mysite`} 176 - language="bash" 177 - /> 178 - </div> 179 - 180 - <div className="space-y-2"> 181 - <p className="text-sm font-medium">List Domains & Sites</p> 182 - <CodeBlock 183 - code={`./wisp-cli list domains your-handle.bsky.social 177 + ./wisp-cli site delete your-handle.bsky.social --site mysite 178 + ./wisp-cli list domains your-handle.bsky.social 184 179 ./wisp-cli list sites your-handle.bsky.social`} 185 - language="bash" 186 - /> 187 - </div> 188 - </CardContent> 189 - </Card> 180 + language="bash" 181 + /> 182 + </div> 183 + </details> 190 184 191 - {/* CI/CD */} 192 - <Card> 193 - <CardHeader> 194 - <CardTitle className="text-base">CI/CD with Tangled Spindle</CardTitle> 195 - <CardDescription> 196 - Deploy automatically on every push using{" "} 197 - <a 198 - href="https://blog.tangled.org/ci" 199 - target="_blank" 200 - rel="noopener noreferrer" 201 - className="underline underline-offset-2" 202 - > 203 - Tangled Spindle 204 - </a> 205 - </CardDescription> 206 - </CardHeader> 207 - <CardContent className="space-y-4"> 208 - <div className="space-y-2"> 209 - <div className="flex items-center gap-2"> 210 - <p className="text-sm font-medium">Simple Deploy</p> 211 - <Badge variant="secondary" className="text-xs"> 212 - Copy Files 213 - </Badge> 214 - </div> 215 - <CodeBlock 216 - code={`steps: 185 + <details className="group border border-border/60 open:border-accent/40"> 186 + <summary className="flex items-center justify-between px-3 py-2.5 bg-muted/30 cursor-pointer hover:bg-muted/50 select-none list-none [&::-webkit-details-marker]:hidden transition-colors"> 187 + <span className="text-sm"> 188 + <span className="text-accent mr-2">$</span> 189 + CI/CD — Tangled Spindle 190 + </span> 191 + <span className="text-accent font-medium text-sm leading-none group-open:hidden">+</span> 192 + <span className="text-accent font-medium text-sm leading-none hidden group-open:inline">−</span> 193 + </summary> 194 + <div className="border-t border-border/50 p-4 space-y-4 bg-background"> 195 + <div className="space-y-1.5"> 196 + <p className="text-xs text-muted-foreground font-medium">Simple deploy</p> 197 + <CodeBlock 198 + code={`steps: 217 199 - name: deploy to wisp 218 200 command: | 219 - curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 201 + curl ${BASE_URL}/wisp-cli-x86_64-linux -o wisp-cli 220 202 chmod +x wisp-cli 221 203 ./wisp-cli deploy "$WISP_HANDLE" \\ 222 204 --path "$SITE_PATH" \\ 223 205 --site "$SITE_NAME" \\ 224 206 --password "$WISP_APP_PASSWORD"`} 225 - language="yaml" 226 - /> 227 - </div> 228 - 229 - <div className="space-y-2"> 230 - <div className="flex items-center gap-2"> 231 - <p className="text-sm font-medium">React / Vite Build & Deploy</p> 232 - <Badge variant="secondary" className="text-xs"> 233 - Full Build 234 - </Badge> 235 - </div> 236 - <CodeBlock 237 - code={`when: 207 + language="yaml" 208 + /> 209 + </div> 210 + <div className="space-y-1.5"> 211 + <p className="text-xs text-muted-foreground font-medium">React / Vite build &amp; deploy</p> 212 + <CodeBlock 213 + code={`when: 238 214 - event: ['push'] 239 215 branch: ['main'] 240 - - event: ['manual'] 241 216 242 217 engine: 'nixery' 243 - 244 - clone: 245 - skip: false 246 - depth: 1 247 - 248 218 dependencies: 249 - nixpkgs: 250 - - nodejs 251 - - coreutils 252 - - curl 253 - github:NixOS/nixpkgs/nixpkgs-unstable: 254 - - bun 219 + nixpkgs: [nodejs, coreutils, curl] 220 + github:NixOS/nixpkgs/nixpkgs-unstable: [bun] 255 221 256 222 environment: 257 223 SITE_PATH: 'dist' ··· 259 225 WISP_HANDLE: 'your-handle.bsky.social' 260 226 261 227 steps: 262 - - name: build site 228 + - name: build 263 229 command: | 264 230 export PATH="$HOME/.nix-profile/bin:$PATH" 265 231 bun install --frozen-lockfile 266 232 bun node_modules/.bin/vite build 267 - 268 - - name: deploy to wisp 233 + - name: deploy 269 234 command: | 270 - curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli 235 + curl ${BASE_URL}/wisp-cli-x86_64-linux -o wisp-cli 271 236 chmod +x wisp-cli 272 237 ./wisp-cli deploy "$WISP_HANDLE" \\ 273 238 --path "$SITE_PATH" \\ 274 239 --site "$SITE_NAME" \\ 275 240 --password "$WISP_APP_PASSWORD"`} 276 - language="yaml" 277 - /> 278 - </div> 279 - 280 - <div className="p-3 bg-muted/30 rounded-lg border-l-4 border-accent text-xs text-muted-foreground"> 281 - <strong className="text-foreground">Note:</strong> Set{" "} 282 - <code className="px-1 py-0.5 bg-background rounded"> 283 - WISP_APP_PASSWORD 284 - </code>{" "} 285 - as a secret in your Tangled Spindle repository settings. 286 - </div> 287 - </CardContent> 288 - </Card> 289 - 290 - {/* Learn More */} 291 - <Card> 292 - <CardContent className="pt-6 space-y-1.5"> 293 - {LINKS.map(({ label, href }) => ( 294 - <a 295 - key={href} 296 - href={href} 297 - target="_blank" 298 - rel="noopener noreferrer" 299 - className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/30 hover:bg-muted transition-colors" 300 - > 301 - <span className="text-sm">{label}</span> 302 - <ExternalLink className="w-4 h-4 text-muted-foreground" /> 303 - </a> 304 - ))} 305 - </CardContent> 306 - </Card> 241 + language="yaml" 242 + /> 243 + </div> 244 + <p className="text-xs text-muted-foreground border-l-2 border-accent/60 pl-3"> 245 + Set <code className="px-1 py-0.5 bg-muted/60 border border-border/50">WISP_APP_PASSWORD</code> as a secret in your Spindle repo settings. 246 + </p> 247 + </div> 248 + </details> 249 + </div> 250 + </div> 307 251 </div> 308 252 ); 309 253 }
+399 -413
apps/main-app/public/editor/tabs/DomainsTab.tsx
··· 1 - import { useState } from 'react' 2 - import { 3 - Card, 4 - CardContent, 5 - CardDescription, 6 - CardHeader, 7 - CardTitle 8 - } from '@public/components/ui/card' 1 + import { useState, useEffect, useRef } from 'react' 9 2 import { Button } from '@public/components/ui/button' 10 3 import { Input } from '@public/components/ui/input' 11 4 import { Label } from '@public/components/ui/label' ··· 50 43 onClaimWispDomain: (handle: string) => Promise<{ success: boolean; error?: string }> 51 44 onCheckWispAvailability: (handle: string) => Promise<{ available: boolean | null }> 52 45 } 46 + 47 + const Kbd = ({ children }: { children: React.ReactNode }) => ( 48 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">{children}</kbd> 49 + ) 53 50 54 51 export function DomainsTab({ 55 52 wispDomains, ··· 79 76 const [viewDomainDNS, setViewDomainDNS] = useState<string | null>(null) 80 77 const [copiedField, setCopiedField] = useState<string | null>(null) 81 78 79 + // Keyboard nav state 80 + const [focusedIndex, setFocusedIndex] = useState(0) 81 + const containerRef = useRef<HTMLDivElement>(null) 82 + const itemRefs = useRef<(HTMLDivElement | null)[]>([]) 83 + const scrollContainerRef = useRef<HTMLDivElement>(null) 84 + 85 + const totalDomains = wispDomains.length + customDomains.length 86 + 87 + // Clamp focusedIndex when domains change 88 + useEffect(() => { 89 + if (totalDomains > 0 && focusedIndex >= totalDomains) { 90 + setFocusedIndex(totalDomains - 1) 91 + } 92 + }, [totalDomains]) 93 + 94 + // Auto-focus when domains first load 95 + useEffect(() => { 96 + if (!domainsLoading && totalDomains > 0 && containerRef.current) { 97 + const timer = setTimeout(() => containerRef.current?.focus(), 100) 98 + return () => clearTimeout(timer) 99 + } 100 + }, [domainsLoading]) 101 + 102 + // Refocus container when a dialog closes 103 + useEffect(() => { 104 + let wasOpen = document.querySelector('[role="dialog"]') !== null 105 + const observer = new MutationObserver(() => { 106 + const isOpen = document.querySelector('[role="dialog"]') !== null 107 + if (wasOpen && !isOpen) setTimeout(() => containerRef.current?.focus(), 50) 108 + wasOpen = isOpen 109 + }) 110 + observer.observe(document.body, { childList: true, subtree: true }) 111 + return () => observer.disconnect() 112 + }, []) 113 + 114 + // Scroll focused item into view 115 + useEffect(() => { 116 + const element = itemRefs.current[focusedIndex] 117 + if (element && scrollContainerRef.current) { 118 + const container = scrollContainerRef.current 119 + const elementRect = element.getBoundingClientRect() 120 + const containerRect = container.getBoundingClientRect() 121 + const isOutOfView = 122 + elementRect.bottom > containerRect.bottom - 50 || 123 + elementRect.top < containerRect.top + 50 124 + if (isOutOfView) element.scrollIntoView({ behavior: 'smooth', block: 'center' }) 125 + } 126 + }, [focusedIndex]) 127 + 128 + // Keyboard navigation 129 + useEffect(() => { 130 + const handleKeyDown = (e: KeyboardEvent) => { 131 + const target = e.target as HTMLElement 132 + const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' 133 + const isDialogOpen = document.querySelector('[role="dialog"]') !== null 134 + const hasFocus = containerRef.current?.contains(document.activeElement) 135 + 136 + if (isTyping || isDialogOpen || !hasFocus || totalDomains === 0) return 137 + 138 + const isWisp = focusedIndex < wispDomains.length 139 + const domain = isWisp 140 + ? wispDomains[focusedIndex] 141 + : customDomains[focusedIndex - wispDomains.length] 142 + 143 + switch (e.key) { 144 + case 'ArrowUp': 145 + e.preventDefault() 146 + setFocusedIndex(prev => Math.max(0, prev - 1)) 147 + break 148 + case 'ArrowDown': 149 + e.preventDefault() 150 + setFocusedIndex(prev => Math.min(totalDomains - 1, prev + 1)) 151 + break 152 + case 'd': 153 + e.preventDefault() 154 + if (isWisp) { 155 + onDeleteWispDomain((domain as WispDomain).domain) 156 + } else { 157 + onDeleteCustomDomain((domain as CustomDomain).id) 158 + } 159 + break 160 + case 'v': 161 + if (!isWisp) { 162 + const cd = domain as CustomDomain 163 + if (!cd.verified && verificationStatus[cd.id] !== 'verifying') { 164 + e.preventDefault() 165 + onVerifyDomain(cd.id) 166 + } 167 + } 168 + break 169 + case 'Enter': 170 + if (!isWisp) { 171 + e.preventDefault() 172 + setViewDomainDNS((domain as CustomDomain).id) 173 + } 174 + break 175 + } 176 + } 177 + 178 + window.addEventListener('keydown', handleKeyDown) 179 + return () => window.removeEventListener('keydown', handleKeyDown) 180 + }, [ 181 + totalDomains, 182 + focusedIndex, 183 + wispDomains, 184 + customDomains, 185 + verificationStatus, 186 + onDeleteWispDomain, 187 + onDeleteCustomDomain, 188 + onVerifyDomain 189 + ]) 190 + 82 191 const copyToClipboard = async (value: string, label: string) => { 83 192 try { 84 193 await navigator.clipboard.writeText(value) 85 194 setCopiedField(label) 86 195 window.setTimeout(() => { 87 - setCopiedField((current) => (current === label ? null : current)) 196 + setCopiedField(current => (current === label ? null : current)) 88 197 }, 1400) 89 198 } catch { 90 199 setCopiedField(null) ··· 92 201 } 93 202 94 203 const checkWispAvailability = async (handle: string) => { 95 - const trimmedHandle = handle.trim().toLowerCase() 96 - if (!trimmedHandle) { 204 + const trimmed = handle.trim().toLowerCase() 205 + if (!trimmed) { 97 206 setWispAvailability({ available: null, checking: false }) 98 207 return 99 208 } 100 - 101 209 setWispAvailability({ available: null, checking: true }) 102 - const result = await onCheckWispAvailability(trimmedHandle) 210 + const result = await onCheckWispAvailability(trimmed) 103 211 setWispAvailability({ available: result.available, checking: false }) 104 212 } 105 213 106 214 const handleClaimWispDomain = async () => { 107 - const trimmedHandle = wispHandle.trim().toLowerCase() 108 - if (!trimmedHandle) { 109 - alert('Please enter a handle') 110 - return 111 - } 112 - 215 + const trimmed = wispHandle.trim().toLowerCase() 216 + if (!trimmed) { alert('Please enter a handle'); return } 113 217 setIsClaimingWisp(true) 114 - const result = await onClaimWispDomain(trimmedHandle) 218 + const result = await onClaimWispDomain(trimmed) 115 219 if (result.success) { 116 220 setWispHandle('') 117 221 setWispAvailability({ available: null, checking: false }) ··· 120 224 } 121 225 122 226 const handleAddCustomDomain = async () => { 123 - if (!customDomain) { 124 - alert('Please enter a domain') 125 - return 126 - } 127 - 227 + if (!customDomain) { alert('Please enter a domain'); return } 128 228 setIsAddingDomain(true) 129 229 const result = await onAddCustomDomain(customDomain) 130 230 setIsAddingDomain(false) 131 - 132 231 if (result.success) { 133 232 setCustomDomain('') 134 233 setAddDomainModalOpen(false) 135 - // Automatically show DNS configuration for the newly added domain 136 - if (result.id) { 137 - setViewDomainDNS(result.id) 138 - } 234 + if (result.id) setViewDomainDNS(result.id) 139 235 } 140 236 } 141 237 238 + const canClaimMore = wispDomains.length < 3 || !!userInfo?.isSupporter 239 + 142 240 return ( 143 241 <> 144 - <div className="space-y-4 min-h-[400px]"> 145 - <Card> 146 - <CardHeader> 147 - <CardTitle>wisp.place Subdomains</CardTitle> 148 - <CardDescription> 149 - {userInfo?.isSupporter 150 - ? 'Your free subdomains on the wisp.place network (unlimited as a supporter)' 151 - : 'Your free subdomains on the wisp.place network (up to 3)'} 152 - </CardDescription> 153 - </CardHeader> 154 - <CardContent> 242 + <div 243 + ref={containerRef} 244 + tabIndex={0} 245 + className="h-full flex flex-col border border-border/30 bg-card/50 font-mono outline-none" 246 + onClick={e => { 247 + const t = e.target as HTMLElement 248 + if (!t.closest('input, textarea, button, select, a, label')) { 249 + containerRef.current?.focus() 250 + } 251 + }} 252 + > 253 + {/* Keyboard hints */} 254 + <div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground p-4 pb-3 border-b border-border/30 flex-shrink-0"> 255 + {totalDomains > 0 ? ( 256 + <> 257 + <div className="flex items-center gap-2"> 258 + <Kbd>↑</Kbd><Kbd>↓</Kbd> 259 + <span>navigate</span> 260 + </div> 261 + <span>•</span> 262 + <div className="flex items-center gap-2"> 263 + <Kbd>d</Kbd><span className="text-red-400">delete</span> 264 + </div> 265 + <span>•</span> 266 + <div className="flex items-center gap-2"> 267 + <Kbd>v</Kbd><span>verify</span> 268 + </div> 269 + <span>•</span> 270 + <div className="flex items-center gap-2"> 271 + <Kbd>Enter</Kbd><span>view DNS</span> 272 + </div> 273 + </> 274 + ) : ( 275 + <span>No domains yet — claim a subdomain or add a custom domain below</span> 276 + )} 277 + </div> 278 + 279 + {/* Scrollable content */} 280 + <div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto"> 281 + 282 + {/* Wisp Domains */} 283 + <div className="p-4 space-y-2"> 284 + <div className="flex items-center justify-between mb-3"> 285 + <p className="text-xs uppercase tracking-wider text-muted-foreground"> 286 + Wisp Domains 287 + </p> 288 + {!userInfo?.isSupporter && ( 289 + <span className="text-xs text-muted-foreground">{wispDomains.length}/3</span> 290 + )} 291 + </div> 292 + 155 293 {domainsLoading ? ( 156 - <div className="space-y-4"> 157 - <div className="space-y-2"> 158 - {[...Array(2)].map((_, i) => ( 294 + <div className="space-y-2"> 295 + {[...Array(2)].map((_, i) => ( 296 + <div key={i} className="p-3 border border-border/30"> 297 + <SkeletonShimmer className="h-5 w-full" /> 298 + </div> 299 + ))} 300 + </div> 301 + ) : wispDomains.length > 0 ? ( 302 + <div className="space-y-2"> 303 + {wispDomains.map((domain, idx) => { 304 + const isFocused = idx === focusedIndex 305 + return ( 159 306 <div 160 - key={i} 161 - className="flex items-center justify-between p-3 border border-border rounded-lg" 307 + key={domain.domain} 308 + ref={el => { itemRefs.current[idx] = el }} 309 + className={`flex items-center justify-between p-3 border transition-colors ${ 310 + isFocused 311 + ? 'border-accent bg-accent/10' 312 + : 'border-border/30 hover:bg-muted/10' 313 + }`} 162 314 > 163 - <div className="flex flex-col gap-2 flex-1"> 315 + <div> 164 316 <div className="flex items-center gap-2"> 165 - <SkeletonShimmer className="h-4 w-4 rounded-full" /> 166 - <SkeletonShimmer className="h-4 w-40" /> 167 - </div> 168 - <SkeletonShimmer className="h-3 w-32 ml-6" /> 169 - </div> 170 - <SkeletonShimmer className="h-8 w-8" /> 171 - </div> 172 - ))} 173 - </div> 174 - <div className="p-4 bg-muted/30 rounded-lg space-y-3"> 175 - <SkeletonShimmer className="h-4 w-full" /> 176 - <div className="space-y-2"> 177 - <SkeletonShimmer className="h-4 w-24" /> 178 - <SkeletonShimmer className="h-10 w-full" /> 179 - </div> 180 - <SkeletonShimmer className="h-10 w-full" /> 181 - </div> 182 - </div> 183 - ) : ( 184 - <div className="space-y-4"> 185 - {wispDomains.length > 0 && ( 186 - <div className="space-y-2"> 187 - {wispDomains.map((domain) => ( 188 - <div 189 - key={domain.domain} 190 - className="flex items-center justify-between p-3 border border-border rounded-lg" 191 - > 192 - <div className="flex flex-col gap-1 flex-1"> 193 - <div className="flex items-center gap-2"> 194 - <CheckCircle2 className="w-4 h-4 text-green-500" /> 195 - <span className="font-mono"> 196 - {domain.domain} 197 - </span> 198 - </div> 199 - {domain.rkey && ( 200 - <p className="text-xs text-muted-foreground ml-6"> 201 - → Mapped to site: {domain.rkey} 202 - </p> 203 - )} 317 + <CheckCircle2 className="w-3 h-3 text-green-500 flex-shrink-0" /> 318 + <span className="text-sm">{domain.domain}</span> 319 + <Badge variant="secondary" className="text-[10px]">wisp</Badge> 204 320 </div> 205 - <Button 206 - variant="ghost" 207 - size="sm" 208 - onClick={() => onDeleteWispDomain(domain.domain)} 209 - > 210 - <Trash2 className="w-4 h-4" /> 211 - </Button> 212 - </div> 213 - ))} 214 - </div> 215 - )} 216 - 217 - {(wispDomains.length < 3 || userInfo?.isSupporter) && ( 218 - <div className="p-4 bg-muted/30 rounded-lg"> 219 - <p className="text-sm text-muted-foreground mb-4"> 220 - {wispDomains.length === 0 221 - ? 'Claim your free wisp.place subdomain' 222 - : userInfo?.isSupporter 223 - ? `Claim another wisp.place subdomain (${wispDomains.length} claimed)` 224 - : `Claim another wisp.place subdomain (${wispDomains.length}/3)`} 225 - </p> 226 - <div className="space-y-3"> 227 - <div className="space-y-2"> 228 - <Label htmlFor="wisp-handle">Choose your handle</Label> 229 - <div className="flex gap-2"> 230 - <div className="flex-1 relative"> 231 - <Input 232 - id="wisp-handle" 233 - placeholder="mysite" 234 - value={wispHandle} 235 - onChange={(e) => { 236 - setWispHandle(e.target.value) 237 - if (e.target.value.trim()) { 238 - checkWispAvailability(e.target.value) 239 - } else { 240 - setWispAvailability({ available: null, checking: false }) 241 - } 242 - }} 243 - disabled={isClaimingWisp} 244 - className="pr-24" 245 - /> 246 - <span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-muted-foreground"> 247 - .wisp.place 248 - </span> 249 - </div> 250 - </div> 251 - {wispAvailability.checking && ( 252 - <p className="text-xs text-muted-foreground flex items-center gap-1"> 253 - <Loader2 className="w-3 h-3 animate-spin" /> 254 - Checking availability... 255 - </p> 256 - )} 257 - {!wispAvailability.checking && wispAvailability.available === true && ( 258 - <p className="text-xs text-green-600 flex items-center gap-1"> 259 - <CheckCircle2 className="w-3 h-3" /> 260 - Available 261 - </p> 262 - )} 263 - {!wispAvailability.checking && wispAvailability.available === false && ( 264 - <p className="text-xs text-red-600 flex items-center gap-1"> 265 - <XCircle className="w-3 h-3" /> 266 - Not available 321 + {domain.rkey && ( 322 + <p className="text-xs text-muted-foreground mt-1 ml-5"> 323 + → {domain.rkey} 267 324 </p> 268 325 )} 269 326 </div> 270 327 <Button 271 - onClick={handleClaimWispDomain} 272 - disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 273 - className="w-full" 328 + variant="ghost" 329 + size="sm" 330 + className="h-7 px-2 flex-shrink-0" 331 + onClick={() => onDeleteWispDomain(domain.domain)} 274 332 > 275 - {isClaimingWisp ? ( 276 - <> 277 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 278 - Claiming... 279 - </> 280 - ) : ( 281 - 'Claim Subdomain' 282 - )} 333 + <Trash2 className="w-3 h-3" /> 283 334 </Button> 284 335 </div> 285 - </div> 286 - )} 336 + ) 337 + })} 338 + </div> 339 + ) : null} 287 340 288 - {wispDomains.length === 3 && !userInfo?.isSupporter && ( 289 - <div className="p-3 bg-muted/30 rounded-lg text-center"> 290 - <p className="text-sm text-muted-foreground"> 291 - You have claimed the maximum of 3 wisp.place subdomains 292 - </p> 341 + {/* Claim form */} 342 + {!domainsLoading && canClaimMore && ( 343 + <div className="mt-2 p-3 border border-dashed border-border/50"> 344 + <p className="text-xs text-muted-foreground mb-3"> 345 + {wispDomains.length === 0 346 + ? 'Claim your free wisp.place subdomain' 347 + : userInfo?.isSupporter 348 + ? `Claim another (${wispDomains.length} claimed)` 349 + : `Claim another (${wispDomains.length}/3)`} 350 + </p> 351 + <div className="space-y-2"> 352 + <Label htmlFor="wisp-handle" className="text-xs">Handle</Label> 353 + <div className="flex gap-2"> 354 + <div className="flex-1 relative"> 355 + <Input 356 + id="wisp-handle" 357 + placeholder="mysite" 358 + value={wispHandle} 359 + onChange={e => { 360 + setWispHandle(e.target.value) 361 + if (e.target.value.trim()) checkWispAvailability(e.target.value) 362 + else setWispAvailability({ available: null, checking: false }) 363 + }} 364 + onKeyDown={e => { if (e.key === 'Enter') handleClaimWispDomain() }} 365 + disabled={isClaimingWisp} 366 + className="pr-24 h-8 text-sm" 367 + /> 368 + <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground"> 369 + .wisp.place 370 + </span> 371 + </div> 372 + <Button 373 + onClick={handleClaimWispDomain} 374 + disabled={!wispHandle.trim() || isClaimingWisp || wispAvailability.available !== true} 375 + size="sm" 376 + className="h-8 flex-shrink-0" 377 + > 378 + {isClaimingWisp ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Claim'} 379 + </Button> 293 380 </div> 294 - )} 381 + {wispAvailability.checking && ( 382 + <p className="text-xs text-muted-foreground flex items-center gap-1"> 383 + <Loader2 className="w-3 h-3 animate-spin" /> 384 + Checking... 385 + </p> 386 + )} 387 + {!wispAvailability.checking && wispAvailability.available === true && ( 388 + <p className="text-xs text-green-600 flex items-center gap-1"> 389 + <CheckCircle2 className="w-3 h-3" /> 390 + Available 391 + </p> 392 + )} 393 + {!wispAvailability.checking && wispAvailability.available === false && ( 394 + <p className="text-xs text-red-600 flex items-center gap-1"> 395 + <XCircle className="w-3 h-3" /> 396 + Not available 397 + </p> 398 + )} 399 + </div> 295 400 </div> 296 401 )} 297 - </CardContent> 298 - </Card> 299 402 300 - <Card> 301 - <CardHeader> 302 - <CardTitle>Custom Domains</CardTitle> 303 - <CardDescription> 304 - Bring your own domain with DNS verification 305 - </CardDescription> 306 - </CardHeader> 307 - <CardContent className="space-y-4"> 308 - <Button 309 - onClick={() => setAddDomainModalOpen(true)} 310 - className="w-full" 311 - > 312 - Add Custom Domain 313 - </Button> 403 + {!domainsLoading && wispDomains.length === 3 && !userInfo?.isSupporter && ( 404 + <p className="text-xs text-muted-foreground text-center py-2"> 405 + Maximum of 3 wisp.place subdomains claimed 406 + </p> 407 + )} 408 + </div> 409 + 410 + {/* Custom Domains */} 411 + <div className="p-4 border-t border-border/30 space-y-2"> 412 + <div className="flex items-center justify-between mb-3"> 413 + <p className="text-xs uppercase tracking-wider text-muted-foreground"> 414 + Custom Domains 415 + </p> 416 + <Button 417 + variant="outline" 418 + size="sm" 419 + className="h-7 text-xs px-3" 420 + onClick={() => setAddDomainModalOpen(true)} 421 + > 422 + + Add Domain 423 + </Button> 424 + </div> 314 425 315 426 {domainsLoading ? ( 316 427 <div className="space-y-2"> 317 428 {[...Array(2)].map((_, i) => ( 318 - <div 319 - key={i} 320 - className="flex items-center justify-between p-3 border border-border rounded-lg" 321 - > 322 - <div className="flex flex-col gap-2 flex-1"> 323 - <div className="flex items-center gap-2"> 324 - <SkeletonShimmer className="h-4 w-4 rounded-full" /> 325 - <SkeletonShimmer className="h-4 w-48" /> 326 - </div> 327 - <SkeletonShimmer className="h-3 w-36 ml-6" /> 328 - </div> 329 - <div className="flex items-center gap-2"> 330 - <SkeletonShimmer className="h-8 w-20" /> 331 - <SkeletonShimmer className="h-8 w-20" /> 332 - <SkeletonShimmer className="h-8 w-8" /> 333 - </div> 429 + <div key={i} className="p-3 border border-border/30"> 430 + <SkeletonShimmer className="h-5 w-full" /> 334 431 </div> 335 432 ))} 336 433 </div> 337 434 ) : customDomains.length === 0 ? ( 338 - <div className="text-center py-4 text-muted-foreground text-sm"> 435 + <p className="text-xs text-muted-foreground py-2"> 339 436 No custom domains added yet 340 - </div> 437 + </p> 341 438 ) : ( 342 439 <div className="space-y-2"> 343 - {customDomains.map((domain) => ( 344 - <div 345 - key={domain.id} 346 - className="flex items-center justify-between p-3 border border-border rounded-lg" 347 - > 348 - <div className="flex flex-col gap-1 flex-1"> 349 - <div className="flex items-center gap-2"> 350 - {domain.verified ? ( 351 - <CheckCircle2 className="w-4 h-4 text-green-500" /> 352 - ) : ( 353 - <XCircle className="w-4 h-4 text-red-500" /> 440 + {customDomains.map((domain, idx) => { 441 + const globalIndex = wispDomains.length + idx 442 + const isFocused = globalIndex === focusedIndex 443 + const isVerifying = verificationStatus[domain.id] === 'verifying' 444 + return ( 445 + <div 446 + key={domain.id} 447 + ref={el => { itemRefs.current[globalIndex] = el }} 448 + className={`flex items-center justify-between p-3 border transition-colors ${ 449 + isFocused 450 + ? 'border-accent bg-accent/10' 451 + : 'border-border/30 hover:bg-muted/10' 452 + }`} 453 + > 454 + <div className="min-w-0 flex-1"> 455 + <div className="flex items-center gap-2 flex-wrap"> 456 + {domain.verified 457 + ? <CheckCircle2 className="w-3 h-3 text-green-500 flex-shrink-0" /> 458 + : <XCircle className="w-3 h-3 text-red-500 flex-shrink-0" /> 459 + } 460 + <span className="text-sm truncate">{domain.domain}</span> 461 + <Badge variant="outline" className="text-[10px]">custom</Badge> 462 + {domain.verified 463 + ? <Badge variant="secondary" className="text-[10px]">✓ verified</Badge> 464 + : <Badge variant="secondary" className="text-[10px] text-yellow-500">⏳ pending</Badge> 465 + } 466 + </div> 467 + {domain.rkey && domain.rkey !== 'self' && ( 468 + <p className="text-xs text-muted-foreground mt-1 ml-5"> 469 + → {domain.rkey} 470 + </p> 354 471 )} 355 - <span className="font-mono"> 356 - {domain.domain} 357 - </span> 358 472 </div> 359 - {domain.rkey && domain.rkey !== 'self' && ( 360 - <p className="text-xs text-muted-foreground ml-6"> 361 - → Mapped to site: {domain.rkey} 362 - </p> 363 - )} 364 - </div> 365 - <div className="flex items-center gap-2"> 366 - <Button 367 - variant="outline" 368 - size="sm" 369 - onClick={() => 370 - setViewDomainDNS(domain.id) 371 - } 372 - > 373 - View DNS 374 - </Button> 375 - {domain.verified ? ( 376 - <Badge variant="secondary"> 377 - Verified 378 - </Badge> 379 - ) : ( 473 + <div className="flex items-center gap-1 flex-shrink-0 ml-2"> 380 474 <Button 381 475 variant="outline" 382 476 size="sm" 383 - onClick={() => 384 - onVerifyDomain(domain.id) 385 - } 386 - disabled={ 387 - verificationStatus[ 388 - domain.id 389 - ] === 'verifying' 390 - } 477 + className="h-7 text-xs px-2" 478 + onClick={() => setViewDomainDNS(domain.id)} 391 479 > 392 - {verificationStatus[ 393 - domain.id 394 - ] === 'verifying' ? ( 395 - <> 396 - <Loader2 className="w-3 h-3 mr-1 animate-spin" /> 397 - Verifying... 398 - </> 399 - ) : ( 400 - 'Verify DNS' 401 - )} 480 + DNS 402 481 </Button> 403 - )} 404 - <Button 405 - variant="ghost" 406 - size="sm" 407 - onClick={() => 408 - onDeleteCustomDomain( 409 - domain.id 410 - ) 411 - } 412 - > 413 - <Trash2 className="w-4 h-4" /> 414 - </Button> 482 + {!domain.verified && ( 483 + <Button 484 + variant="outline" 485 + size="sm" 486 + className="h-7 text-xs px-2" 487 + onClick={() => onVerifyDomain(domain.id)} 488 + disabled={isVerifying} 489 + > 490 + {isVerifying 491 + ? <Loader2 className="w-3 h-3 animate-spin" /> 492 + : 'Verify'} 493 + </Button> 494 + )} 495 + <Button 496 + variant="ghost" 497 + size="sm" 498 + className="h-7 px-2" 499 + onClick={() => onDeleteCustomDomain(domain.id)} 500 + > 501 + <Trash2 className="w-3 h-3" /> 502 + </Button> 503 + </div> 415 504 </div> 416 - </div> 417 - ))} 505 + ) 506 + })} 418 507 </div> 419 508 )} 420 - </CardContent> 421 - </Card> 509 + </div> 510 + </div> 422 511 </div> 423 512 424 513 {/* Add Custom Domain Modal */} ··· 438 527 id="new-domain" 439 528 placeholder="example.com" 440 529 value={customDomain} 441 - onChange={(e) => setCustomDomain(e.target.value)} 530 + onChange={e => setCustomDomain(e.target.value)} 531 + onKeyDown={e => { if (e.key === 'Enter') handleAddCustomDomain() }} 442 532 /> 443 533 <p className="text-xs text-muted-foreground"> 444 534 After adding, click "View DNS" to see the records you ··· 449 539 <DialogFooter className="flex-col sm:flex-row gap-2"> 450 540 <Button 451 541 variant="outline" 452 - onClick={() => { 453 - setAddDomainModalOpen(false) 454 - setCustomDomain('') 455 - }} 542 + onClick={() => { setAddDomainModalOpen(false); setCustomDomain('') }} 456 543 className="w-full sm:w-auto" 457 544 disabled={isAddingDomain} 458 545 > ··· 464 551 className="w-full sm:w-auto" 465 552 > 466 553 {isAddingDomain ? ( 467 - <> 468 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 469 - Adding... 470 - </> 554 + <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Adding...</> 471 555 ) : ( 472 556 'Add Domain' 473 557 )} ··· 479 563 {/* View DNS Records Modal */} 480 564 <Dialog 481 565 open={viewDomainDNS !== null} 482 - onOpenChange={(open) => !open && setViewDomainDNS(null)} 566 + onOpenChange={open => !open && setViewDomainDNS(null)} 483 567 > 484 568 <DialogContent className="sm:max-w-lg max-h-[80vh] overflow-hidden"> 485 569 <DialogHeader> ··· 492 576 <div className="relative max-h-[62vh] overflow-y-auto pr-2"> 493 577 <div className="pointer-events-none sticky top-0 z-10 h-3 bg-gradient-to-b from-background to-transparent" /> 494 578 {(() => { 495 - const domain = customDomains.find( 496 - (d) => d.id === viewDomainDNS 497 - ) 579 + const domain = customDomains.find(d => d.id === viewDomainDNS) 498 580 if (!domain) return null 499 - 500 581 return ( 501 582 <div className="space-y-4 py-4"> 502 583 <div className="p-3 bg-muted/30 rounded-lg"> 503 - <p className="text-xs uppercase tracking-wide text-muted-foreground"> 504 - Domain 505 - </p> 506 - <p className="font-mono text-sm mt-1"> 507 - {domain.domain} 508 - </p> 584 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Domain</p> 585 + <p className="font-mono text-sm mt-1">{domain.domain}</p> 509 586 </div> 510 587 511 588 <div className="space-y-3"> 512 589 <div className="p-4 bg-background rounded border border-border"> 513 590 <div className="flex items-center justify-between gap-3"> 514 591 <div> 515 - <p className="text-xs uppercase tracking-wide text-muted-foreground"> 516 - Step 1 517 - </p> 518 - <p className="text-sm font-semibold"> 519 - Verify ownership (TXT) 520 - </p> 592 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Step 1</p> 593 + <p className="text-sm font-semibold">Verify ownership (TXT)</p> 521 594 </div> 522 - <Badge variant="secondary" className="text-xs"> 523 - Required 524 - </Badge> 595 + <Badge variant="secondary" className="text-xs">Required</Badge> 525 596 </div> 526 597 <div className="mt-3 space-y-2"> 527 598 <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"> 528 599 <div className="min-w-0"> 529 - <p className="text-xs text-muted-foreground"> 530 - Name 531 - </p> 532 - <p className="font-mono text-sm select-all"> 533 - _wisp.{domain.domain} 534 - </p> 600 + <p className="text-xs text-muted-foreground">Name</p> 601 + <p className="font-mono text-sm select-all">_wisp.{domain.domain}</p> 535 602 </div> 536 - <Button 537 - variant="outline" 538 - size="sm" 539 - onClick={() => 540 - copyToClipboard( 541 - `_wisp.${domain.domain}`, 542 - 'txt-name' 543 - ) 544 - } 545 - > 546 - {copiedField === 'txt-name' 547 - ? 'Copied' 548 - : 'Copy'} 603 + <Button variant="outline" size="sm" onClick={() => copyToClipboard(`_wisp.${domain.domain}`, 'txt-name')}> 604 + {copiedField === 'txt-name' ? 'Copied' : 'Copy'} 549 605 </Button> 550 606 </div> 551 607 <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"> 552 608 <div className="min-w-0"> 553 - <p className="text-xs text-muted-foreground"> 554 - Value 555 - </p> 556 - <p className="font-mono text-sm break-all select-all"> 557 - {userInfo.did} 558 - </p> 609 + <p className="text-xs text-muted-foreground">Value</p> 610 + <p className="font-mono text-sm break-all select-all">{userInfo.did}</p> 559 611 </div> 560 - <Button 561 - variant="outline" 562 - size="sm" 563 - onClick={() => 564 - copyToClipboard(userInfo.did, 'txt-value') 565 - } 566 - > 567 - {copiedField === 'txt-value' 568 - ? 'Copied' 569 - : 'Copy'} 612 + <Button variant="outline" size="sm" onClick={() => copyToClipboard(userInfo.did, 'txt-value')}> 613 + {copiedField === 'txt-value' ? 'Copied' : 'Copy'} 570 614 </Button> 571 615 </div> 572 616 </div> ··· 575 619 <div className="p-4 bg-background rounded border border-border"> 576 620 <div className="flex items-center justify-between gap-3"> 577 621 <div> 578 - <p className="text-xs uppercase tracking-wide text-muted-foreground"> 579 - Step 2 580 - </p> 581 - <p className="text-sm font-semibold"> 582 - Point your domain (CNAME) 583 - </p> 622 + <p className="text-xs uppercase tracking-wide text-muted-foreground">Step 2</p> 623 + <p className="text-sm font-semibold">Point your domain (CNAME)</p> 584 624 </div> 585 - <Badge variant="secondary" className="text-xs"> 586 - Recommended 587 - </Badge> 625 + <Badge variant="secondary" className="text-xs">Recommended</Badge> 588 626 </div> 589 627 <div className="mt-3 space-y-2"> 590 628 <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"> 591 629 <div className="min-w-0"> 592 - <p className="text-xs text-muted-foreground"> 593 - Name 594 - </p> 595 - <p className="font-mono text-sm select-all"> 596 - {domain.domain} 597 - </p> 630 + <p className="text-xs text-muted-foreground">Name</p> 631 + <p className="font-mono text-sm select-all">{domain.domain}</p> 598 632 </div> 599 - <Button 600 - variant="outline" 601 - size="sm" 602 - onClick={() => 603 - copyToClipboard(domain.domain, 'cname-name') 604 - } 605 - > 606 - {copiedField === 'cname-name' 607 - ? 'Copied' 608 - : 'Copy'} 633 + <Button variant="outline" size="sm" onClick={() => copyToClipboard(domain.domain, 'cname-name')}> 634 + {copiedField === 'cname-name' ? 'Copied' : 'Copy'} 609 635 </Button> 610 636 </div> 611 637 <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2"> 612 638 <div className="min-w-0"> 613 - <p className="text-xs text-muted-foreground"> 614 - Value 615 - </p> 616 - <p className="font-mono text-sm select-all"> 617 - {domain.id}.dns.wisp.place 618 - </p> 639 + <p className="text-xs text-muted-foreground">Value</p> 640 + <p className="font-mono text-sm select-all">{domain.id}.dns.wisp.place</p> 619 641 </div> 620 - <Button 621 - variant="outline" 622 - size="sm" 623 - onClick={() => 624 - copyToClipboard( 625 - `${domain.id}.dns.wisp.place`, 626 - 'cname-value' 627 - ) 628 - } 629 - > 630 - {copiedField === 'cname-value' 631 - ? 'Copied' 632 - : 'Copy'} 642 + <Button variant="outline" size="sm" onClick={() => copyToClipboard(`${domain.id}.dns.wisp.place`, 'cname-value')}> 643 + {copiedField === 'cname-value' ? 'Copied' : 'Copy'} 633 644 </Button> 634 645 </div> 635 646 </div> ··· 655 666 </p> 656 667 </div> 657 668 <div className="space-y-3"> 658 - {HOSTING_NODES.map((node) => ( 669 + {HOSTING_NODES.map(node => ( 659 670 <div key={node.ip} className="space-y-2 pl-3 border-l-2 border-muted"> 660 - <div className="font-semibold text-muted-foreground mb-1"> 661 - {node.region} 662 - </div> 671 + <div className="font-semibold text-muted-foreground mb-1">{node.region}</div> 663 672 <div className="font-mono text-xs space-y-1"> 664 673 <div> 665 - <span className="text-muted-foreground"> 666 - Name: 667 - </span>{' '} 668 - <span className="select-all"> 669 - {domain.domain} 670 - </span> 674 + <span className="text-muted-foreground">Name:</span>{' '} 675 + <span className="select-all">{domain.domain}</span> 671 676 </div> 672 677 <div> 673 - <span className="text-muted-foreground"> 674 - Type: 675 - </span>{' '} 678 + <span className="text-muted-foreground">Type:</span>{' '} 676 679 <span>A</span> 677 680 </div> 678 681 </div> 679 682 <div className="flex items-center justify-between gap-3 rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs"> 680 683 <div className="min-w-0"> 681 - <p className="text-xs text-muted-foreground"> 682 - Value 683 - </p> 684 - <p className="select-all"> 685 - {node.ip} 686 - </p> 684 + <p className="text-xs text-muted-foreground">Value</p> 685 + <p className="select-all">{node.ip}</p> 687 686 </div> 688 - <Button 689 - variant="outline" 690 - size="sm" 691 - onClick={() => 692 - copyToClipboard( 693 - node.ip, 694 - `a-value-${node.ip}` 695 - ) 696 - } 697 - > 698 - {copiedField === `a-value-${node.ip}` 699 - ? 'Copied' 700 - : 'Copy'} 687 + <Button variant="outline" size="sm" onClick={() => copyToClipboard(node.ip, `a-value-${node.ip}`)}> 688 + {copiedField === `a-value-${node.ip}` ? 'Copied' : 'Copy'} 701 689 </Button> 702 690 </div> 703 691 </div> ··· 721 709 ) 722 710 })()} 723 711 <div className="pointer-events-none sticky bottom-0 z-10 flex h-8 items-end justify-center bg-gradient-to-t from-background to-transparent"> 724 - <span className="text-[10px] text-muted-foreground"> 725 - Scroll for more 726 - </span> 712 + <span className="text-[10px] text-muted-foreground">Scroll for more</span> 727 713 </div> 728 714 </div> 729 715 )}
+201 -302
apps/main-app/public/editor/tabs/UploadTab.tsx
··· 1 1 import { useState, useEffect, useRef } from 'react' 2 - import { 3 - Card, 4 - CardContent, 5 - CardDescription, 6 - CardHeader, 7 - CardTitle 8 - } from '@public/components/ui/card' 9 2 import { Button } from '@public/components/ui/button' 10 3 import { Input } from '@public/components/ui/input' 11 4 import { Label } from '@public/components/ui/label' 12 - import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 13 - import { Badge } from '@public/components/ui/badge' 14 5 import { 15 - Globe, 16 6 Upload, 17 7 AlertCircle, 18 8 Loader2, ··· 428 418 } 429 419 430 420 return ( 431 - <div className="space-y-4 min-h-[400px]"> 432 - <Card> 433 - <CardHeader> 434 - <CardTitle>Upload Site</CardTitle> 435 - <CardDescription> 436 - Deploy a new site from a folder or Git repository 437 - </CardDescription> 438 - </CardHeader> 439 - <CardContent className="space-y-6"> 440 - <div className="space-y-4"> 441 - <div className="p-4 bg-muted/50 rounded-lg"> 442 - <RadioGroup 443 - value={siteMode} 444 - onValueChange={(value) => setSiteMode(value as 'existing' | 'new')} 421 + <div className="h-full flex flex-col border border-border/30 bg-card/50 font-mono"> 422 + {/* Header */} 423 + <div className="p-4 pb-3 border-b border-border/30 flex-shrink-0"> 424 + <p className="text-sm font-semibold">Upload Site</p> 425 + <p className="text-xs text-muted-foreground mt-0.5">100MB per file · 300MB total</p> 426 + </div> 427 + 428 + {/* Content */} 429 + <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-3"> 430 + 431 + {/* Mode toggle */} 432 + <div className="flex border border-border/30 overflow-hidden"> 433 + <button 434 + className={`flex-1 py-2 text-sm transition-colors ${ 435 + siteMode === 'existing' 436 + ? 'bg-accent/20 text-foreground' 437 + : 'text-muted-foreground hover:bg-muted/30' 438 + }`} 439 + onClick={() => setSiteMode('existing')} 440 + disabled={isUploading} 441 + > 442 + Update existing 443 + </button> 444 + <button 445 + className={`flex-1 py-2 text-sm border-l border-border/30 transition-colors ${ 446 + siteMode === 'new' 447 + ? 'bg-accent/20 text-foreground' 448 + : 'text-muted-foreground hover:bg-muted/30' 449 + }`} 450 + onClick={() => setSiteMode('new')} 451 + disabled={isUploading} 452 + > 453 + Create new 454 + </button> 455 + </div> 456 + 457 + {/* Site selector / name */} 458 + {siteMode === 'existing' ? ( 459 + sitesLoading ? ( 460 + <div className="flex items-center gap-2 p-3 border border-border/30 text-xs text-muted-foreground"> 461 + <Loader2 className="w-3 h-3 animate-spin" /> 462 + Loading sites... 463 + </div> 464 + ) : sites.length === 0 ? ( 465 + <p className="text-xs text-muted-foreground p-3 border border-dashed border-border/50"> 466 + No sites yet — switch to "Create new" above. 467 + </p> 468 + ) : ( 469 + <div className="space-y-1"> 470 + <Label htmlFor="site-select" className="text-xs">Site</Label> 471 + <select 472 + id="site-select" 473 + className="flex h-9 w-full border border-input bg-background px-3 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" 474 + value={selectedSiteRkey} 475 + onChange={(e) => setSelectedSiteRkey(e.target.value)} 445 476 disabled={isUploading} 446 477 > 447 - <div className="flex items-center space-x-2"> 448 - <RadioGroupItem value="existing" id="existing" /> 449 - <Label htmlFor="existing" className="cursor-pointer"> 450 - Update existing site 451 - </Label> 452 - </div> 453 - <div className="flex items-center space-x-2"> 454 - <RadioGroupItem value="new" id="new" /> 455 - <Label htmlFor="new" className="cursor-pointer"> 456 - Create new site 457 - </Label> 458 - </div> 459 - </RadioGroup> 478 + <option value="">Select a site...</option> 479 + {sites.map((site) => ( 480 + <option key={site.rkey} value={site.rkey}> 481 + {site.display_name || site.rkey} 482 + </option> 483 + ))} 484 + </select> 485 + </div> 486 + ) 487 + ) : ( 488 + <div className="space-y-1"> 489 + <Label htmlFor="new-site-name" className="text-xs">Site name</Label> 490 + <Input 491 + id="new-site-name" 492 + placeholder="my-awesome-site" 493 + value={newSiteName} 494 + onChange={(e) => setNewSiteName(e.target.value)} 495 + disabled={isUploading} 496 + className="h-9" 497 + /> 498 + </div> 499 + )} 500 + 501 + {/* Drop zone */} 502 + <div 503 + ref={dropZoneRef} 504 + className={`border-2 border-dashed p-4 flex items-center gap-3 transition-colors ${ 505 + isDragging 506 + ? 'border-accent bg-accent/10 cursor-copy' 507 + : isUploading 508 + ? 'opacity-50 cursor-not-allowed border-border/30' 509 + : 'border-border/30 hover:border-accent cursor-pointer' 510 + }`} 511 + onDrop={handleDrop} 512 + onDragOver={handleDragOver} 513 + onDragEnter={handleDragEnter} 514 + onDragLeave={handleDragLeave} 515 + onClick={() => !isUploading && document.getElementById('file-upload')?.click()} 516 + > 517 + <Upload className={`w-4 h-4 flex-shrink-0 transition-colors ${isDragging ? 'text-accent' : 'text-muted-foreground'}`} /> 518 + <span className="text-sm text-muted-foreground flex-1"> 519 + {isDragging 520 + ? 'Drop here...' 521 + : selectedFiles && selectedFiles.length > 0 522 + ? <span className="text-accent font-medium">{selectedFiles.length} files selected</span> 523 + : 'Drop a folder or click to choose' 524 + } 525 + </span> 526 + <input 527 + type="file" 528 + id="file-upload" 529 + multiple 530 + onChange={handleFileSelect} 531 + className="hidden" 532 + {...(({ webkitdirectory: '', directory: '' } as any))} 533 + disabled={isUploading} 534 + /> 535 + </div> 536 + 537 + {/* Progress */} 538 + {uploadProgress && ( 539 + <div className="space-y-2"> 540 + <div className="flex items-center gap-2 p-3 bg-muted/50 border border-border/30 text-sm"> 541 + <Loader2 className="w-3 h-3 animate-spin flex-shrink-0" /> 542 + <span>{uploadProgress}</span> 460 543 </div> 461 544 462 - {siteMode === 'existing' ? ( 463 - <div className="space-y-2"> 464 - <Label htmlFor="site-select">Select Site</Label> 465 - {sitesLoading ? ( 466 - <div className="flex items-center justify-center py-4"> 467 - <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /> 468 - </div> 469 - ) : sites.length === 0 ? ( 470 - <div className="p-4 border border-dashed rounded-lg text-center text-sm text-muted-foreground"> 471 - No sites available. Create a new site instead. 545 + {fileProgressList.length > 0 && ( 546 + <div className="border border-border/30 overflow-hidden"> 547 + <button 548 + onClick={() => setShowFileProgress(!showFileProgress)} 549 + className="w-full px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors flex items-center justify-between text-xs" 550 + > 551 + <span> 552 + Files ({fileProgressList.filter(f => f.status === 'uploaded' || f.status === 'reused').length}/{fileProgressList.length}) 553 + </span> 554 + {showFileProgress ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />} 555 + </button> 556 + {showFileProgress && ( 557 + <div className="max-h-48 overflow-y-auto p-2 space-y-0.5 bg-background"> 558 + {fileProgressList.map((file, idx) => ( 559 + <div key={idx} className="flex items-start gap-2 text-xs p-1.5 hover:bg-muted/30"> 560 + {file.status === 'checking' && <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-blue-500 shrink-0" />} 561 + {file.status === 'uploading' && <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-purple-500 shrink-0" />} 562 + {file.status === 'uploaded' && <CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" />} 563 + {file.status === 'reused' && <RefreshCw className="w-3 h-3 mt-0.5 text-cyan-500 shrink-0" />} 564 + {file.status === 'failed' && <XCircle className="w-3 h-3 mt-0.5 text-red-500 shrink-0" />} 565 + <div className="flex-1 min-w-0"> 566 + <div className="font-mono truncate">{file.name}</div> 567 + {file.error && <div className="text-red-500">{file.error}</div>} 568 + {file.status === 'checking' && <div className="text-muted-foreground">Checking...</div>} 569 + {file.status === 'uploading' && <div className="text-muted-foreground">Uploading...</div>} 570 + {file.status === 'reused' && <div className="text-muted-foreground">Unchanged</div>} 571 + </div> 572 + </div> 573 + ))} 472 574 </div> 473 - ) : ( 474 - <select 475 - id="site-select" 476 - className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" 477 - value={selectedSiteRkey} 478 - onChange={(e) => setSelectedSiteRkey(e.target.value)} 479 - disabled={isUploading} 480 - > 481 - <option value="">Select a site...</option> 482 - {sites.map((site) => ( 483 - <option key={site.rkey} value={site.rkey}> 484 - {site.display_name || site.rkey} 485 - </option> 486 - ))} 487 - </select> 488 575 )} 489 576 </div> 490 - ) : ( 491 - <div className="space-y-2"> 492 - <Label htmlFor="new-site-name">New Site Name</Label> 493 - <Input 494 - id="new-site-name" 495 - placeholder="my-awesome-site" 496 - value={newSiteName} 497 - onChange={(e) => setNewSiteName(e.target.value)} 498 - disabled={isUploading} 499 - /> 500 - </div> 501 577 )} 502 578 503 - <p className="text-xs text-muted-foreground"> 504 - File limits: 100MB per file, 300MB total 505 - </p> 506 - </div> 507 - 508 - <div className="grid md:grid-cols-2 gap-4"> 509 - <Card 510 - ref={dropZoneRef} 511 - className={`border-2 border-dashed transition-colors cursor-pointer ${ 512 - isDragging 513 - ? 'border-accent bg-accent/10' 514 - : 'hover:border-accent' 515 - } ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`} 516 - onDrop={handleDrop} 517 - onDragOver={handleDragOver} 518 - onDragEnter={handleDragEnter} 519 - onDragLeave={handleDragLeave} 520 - > 521 - <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 522 - <Upload className={`w-12 h-12 mb-4 transition-colors ${ 523 - isDragging ? 'text-accent' : 'text-muted-foreground' 524 - }`} /> 525 - <h3 className="font-semibold mb-2"> 526 - Upload Folder 527 - </h3> 528 - <p className="text-sm text-muted-foreground mb-4"> 529 - {isDragging 530 - ? 'Drop your files here...' 531 - : 'Drag and drop or click to upload your static site files' 532 - } 533 - </p> 534 - <input 535 - type="file" 536 - id="file-upload" 537 - multiple 538 - onChange={handleFileSelect} 539 - className="hidden" 540 - {...(({ webkitdirectory: '', directory: '' } as any))} 541 - disabled={isUploading} 542 - /> 543 - <label htmlFor="file-upload"> 544 - <Button 545 - variant="outline" 546 - type="button" 547 - onClick={() => 548 - document 549 - .getElementById('file-upload') 550 - ?.click() 551 - } 552 - disabled={isUploading} 553 - > 554 - Choose Folder 555 - </Button> 556 - </label> 557 - {selectedFiles && selectedFiles.length > 0 && ( 558 - <p className="text-sm text-accent mt-3 font-medium"> 559 - {selectedFiles.length} files selected 560 - </p> 561 - )} 562 - </CardContent> 563 - </Card> 564 - 565 - <Card className="border-2 border-dashed opacity-50"> 566 - <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 567 - <Globe className="w-12 h-12 text-muted-foreground mb-4" /> 568 - <h3 className="font-semibold mb-2"> 569 - Connect Git Repository 570 - </h3> 571 - <p className="text-sm text-muted-foreground mb-4"> 572 - Link your GitHub, GitLab, or any Git 573 - repository 574 - </p> 575 - <Badge variant="secondary">Coming soon!</Badge> 576 - </CardContent> 577 - </Card> 578 - </div> 579 - 580 - {uploadProgress && ( 581 - <div className="space-y-3"> 582 - <div className="p-4 bg-muted rounded-lg"> 583 - <div className="flex items-center gap-2"> 584 - <Loader2 className="w-4 h-4 animate-spin" /> 585 - <span className="text-sm">{uploadProgress}</span> 579 + {failedFiles.length > 0 && ( 580 + <div className="p-3 bg-red-500/10 border border-red-500/20 text-xs space-y-1"> 581 + <div className="flex items-center gap-2 text-red-400 font-medium"> 582 + <AlertCircle className="w-3 h-3 shrink-0" /> 583 + {failedFiles.length} file{failedFiles.length > 1 ? 's' : ''} failed 584 + {uploadedCount > 0 && <span className="font-normal text-muted-foreground">({uploadedCount} ok)</span>} 585 + </div> 586 + <div className="ml-5 space-y-1 max-h-32 overflow-y-auto"> 587 + {failedFiles.slice(0, 10).map((file, idx) => ( 588 + <div key={idx}> 589 + <span className="font-mono">{file.name}</span> 590 + <span className="text-muted-foreground"> — {file.error}{file.size > 0 && ` (${(file.size / 1024).toFixed(1)}KB)`}</span> 591 + </div> 592 + ))} 593 + {failedFiles.length > 10 && <div className="text-muted-foreground">…and {failedFiles.length - 10} more</div>} 586 594 </div> 587 595 </div> 596 + )} 588 597 589 - {fileProgressList.length > 0 && ( 590 - <div className="border rounded-lg overflow-hidden"> 591 - <button 592 - onClick={() => setShowFileProgress(!showFileProgress)} 593 - className="w-full p-3 bg-muted/50 hover:bg-muted transition-colors flex items-center justify-between text-sm font-medium" 594 - > 595 - <span> 596 - Processing files ({fileProgressList.filter(f => f.status === 'uploaded' || f.status === 'reused').length}/{fileProgressList.length}) 597 - </span> 598 - {showFileProgress ? ( 599 - <ChevronUp className="w-4 h-4" /> 600 - ) : ( 601 - <ChevronDown className="w-4 h-4" /> 602 - )} 603 - </button> 604 - {showFileProgress && ( 605 - <div className="max-h-64 overflow-y-auto p-3 space-y-1 bg-background"> 606 - {fileProgressList.map((file, idx) => ( 607 - <div 608 - key={idx} 609 - className="flex items-start gap-2 text-xs p-2 rounded hover:bg-muted/50 transition-colors" 610 - > 611 - {file.status === 'checking' && ( 612 - <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-blue-500 shrink-0" /> 613 - )} 614 - {file.status === 'uploading' && ( 615 - <Loader2 className="w-3 h-3 mt-0.5 animate-spin text-purple-500 shrink-0" /> 616 - )} 617 - {file.status === 'uploaded' && ( 618 - <CheckCircle2 className="w-3 h-3 mt-0.5 text-green-500 shrink-0" /> 619 - )} 620 - {file.status === 'reused' && ( 621 - <RefreshCw className="w-3 h-3 mt-0.5 text-cyan-500 shrink-0" /> 622 - )} 623 - {file.status === 'failed' && ( 624 - <XCircle className="w-3 h-3 mt-0.5 text-red-500 shrink-0" /> 625 - )} 626 - <div className="flex-1 min-w-0"> 627 - <div className="font-mono truncate">{file.name}</div> 628 - {file.error && ( 629 - <div className="text-red-500 mt-0.5"> 630 - {file.error} 631 - </div> 632 - )} 633 - {file.status === 'checking' && ( 634 - <div className="text-muted-foreground">Checking for changes...</div> 635 - )} 636 - {file.status === 'uploading' && ( 637 - <div className="text-muted-foreground">Uploading to PDS...</div> 638 - )} 639 - {file.status === 'reused' && ( 640 - <div className="text-muted-foreground">Reused (unchanged)</div> 641 - )} 642 - </div> 643 - </div> 644 - ))} 645 - </div> 646 - )} 598 + {skippedFiles.length > 0 && ( 599 + <div className="p-3 bg-yellow-500/10 border border-yellow-500/20 text-xs space-y-1"> 600 + <div className="flex items-center gap-2 text-yellow-500 font-medium"> 601 + <AlertCircle className="w-3 h-3 shrink-0" /> 602 + {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 647 603 </div> 648 - )} 649 - 650 - {failedFiles.length > 0 && ( 651 - <div className="p-4 bg-red-500/10 border border-red-500/20 rounded-lg"> 652 - <div className="flex items-start gap-2 text-red-600 dark:text-red-400 mb-2"> 653 - <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 654 - <div className="flex-1"> 655 - <span className="font-medium"> 656 - {failedFiles.length} file{failedFiles.length > 1 ? 's' : ''} failed to upload 657 - </span> 658 - {uploadedCount > 0 && ( 659 - <span className="text-sm ml-2"> 660 - ({uploadedCount} uploaded successfully) 661 - </span> 662 - )} 604 + <div className="ml-5 space-y-0.5 max-h-24 overflow-y-auto"> 605 + {skippedFiles.slice(0, 5).map((file, idx) => ( 606 + <div key={idx}> 607 + <span className="font-mono">{file.name}</span> 608 + <span className="text-muted-foreground"> — {file.reason}</span> 663 609 </div> 664 - </div> 665 - <div className="ml-6 space-y-1 max-h-40 overflow-y-auto"> 666 - {failedFiles.slice(0, 10).map((file, idx) => ( 667 - <div key={idx} className="text-xs"> 668 - <div className="font-mono font-semibold">{file.name}</div> 669 - <div className="text-muted-foreground ml-2"> 670 - Error: {file.error} 671 - {file.size > 0 && ` (${(file.size / 1024).toFixed(1)} KB)`} 672 - </div> 673 - </div> 674 - ))} 675 - {failedFiles.length > 10 && ( 676 - <div className="text-xs text-muted-foreground"> 677 - ...and {failedFiles.length - 10} more 678 - </div> 679 - )} 680 - </div> 610 + ))} 611 + {skippedFiles.length > 5 && <div className="text-muted-foreground">…and {skippedFiles.length - 5} more</div>} 681 612 </div> 682 - )} 613 + </div> 614 + )} 615 + </div> 616 + )} 683 617 684 - {skippedFiles.length > 0 && ( 685 - <div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"> 686 - <div className="flex items-start gap-2 text-yellow-600 dark:text-yellow-400 mb-2"> 687 - <AlertCircle className="w-4 h-4 mt-0.5 shrink-0" /> 688 - <div className="flex-1"> 689 - <span className="font-medium"> 690 - {skippedFiles.length} file{skippedFiles.length > 1 ? 's' : ''} skipped 691 - </span> 692 - </div> 693 - </div> 694 - <div className="ml-6 space-y-1 max-h-32 overflow-y-auto"> 695 - {skippedFiles.slice(0, 5).map((file, idx) => ( 696 - <div key={idx} className="text-xs"> 697 - <span className="font-mono">{file.name}</span> 698 - <span className="text-muted-foreground"> - {file.reason}</span> 699 - </div> 700 - ))} 701 - {skippedFiles.length > 5 && ( 702 - <div className="text-xs text-muted-foreground"> 703 - ...and {skippedFiles.length - 5} more 704 - </div> 705 - )} 706 - </div> 707 - </div> 708 - )} 709 - </div> 618 + {/* Upload button */} 619 + <Button 620 + onClick={handleUpload} 621 + className="w-full" 622 + disabled={ 623 + (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 624 + isUploading || 625 + (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 626 + } 627 + > 628 + {isUploading ? ( 629 + <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Uploading...</> 630 + ) : siteMode === 'existing' ? ( 631 + 'Update Site' 632 + ) : selectedFiles && selectedFiles.length > 0 ? ( 633 + 'Upload & Deploy' 634 + ) : ( 635 + 'Create Empty Site' 710 636 )} 711 - 712 - <Button 713 - onClick={handleUpload} 714 - className="w-full" 715 - disabled={ 716 - (siteMode === 'existing' ? !selectedSiteRkey : !newSiteName) || 717 - isUploading || 718 - (siteMode === 'existing' && (!selectedFiles || selectedFiles.length === 0)) 719 - } 720 - > 721 - {isUploading ? ( 722 - <> 723 - <Loader2 className="w-4 h-4 mr-2 animate-spin" /> 724 - Uploading... 725 - </> 726 - ) : ( 727 - <> 728 - {siteMode === 'existing' ? ( 729 - 'Update Site' 730 - ) : ( 731 - selectedFiles && selectedFiles.length > 0 732 - ? 'Upload & Deploy' 733 - : 'Create Empty Site' 734 - )} 735 - </> 736 - )} 737 - </Button> 738 - </CardContent> 739 - </Card> 637 + </Button> 638 + </div> 740 639 </div> 741 640 ) 742 641 }
+1 -1
bun.lock
··· 139 139 }, 140 140 "cli": { 141 141 "name": "wispctl", 142 - "version": "1.0.8", 142 + "version": "1.0.10", 143 143 "bin": { 144 144 "wispctl": "dist/index.js", 145 145 },
+1 -1
packages/@wispplace/bun-firehose/src/firehose.ts
··· 1 1 /** 2 2 * Bun-compatible AT Protocol Firehose 3 - * Uses our BunSubscription with the SDK's parsing/validation logic 3 + * Uses BunSubscription with the SDK's parsing/validation logic 4 4 */ 5 5 6 6 import { IdResolver } from '@atproto/identity';
+5 -3
packages/@wispplace/bun-firehose/src/runtime.ts
··· 1 1 /** 2 2 * Runtime detection utilities for cross-platform compatibility 3 3 */ 4 - 5 4 declare const Bun: unknown; 5 + declare const Deno: unknown; 6 6 7 7 export const isBun = typeof Bun !== 'undefined'; 8 - export const isNode = typeof process !== 'undefined' && !isBun; 8 + export const isDeno = typeof Deno !== 'undefined'; 9 + export const isNode = typeof process !== 'undefined' && !isBun && !isDeno; 9 10 10 11 /** 11 12 * Get the current runtime name for logging 12 13 */ 13 14 export function getRuntimeName(): string { 14 15 if (isBun) return 'Bun'; 16 + if (isDeno) return 'Deno'; 15 17 if (isNode) return 'Node.js'; 16 18 return 'Unknown'; 17 - } 19 + }