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

redesign dashboard

+705 -685
+206 -514
apps/main-app/public/editor/editor.tsx
··· 1 1 import { useState, useEffect } from 'react' 2 2 import { createRoot } from 'react-dom/client' 3 - import { BrowserRouter, Routes, Route, Link, useNavigate } from 'react-router-dom' 3 + import { BrowserRouter, Routes, Route } from 'react-router-dom' 4 4 import { Button } from '@public/components/ui/button' 5 5 import { 6 6 Tabs, ··· 22 22 import { SkeletonShimmer } from '@public/components/ui/skeleton' 23 23 import { Input } from '@public/components/ui/input' 24 24 import { RadioGroup, RadioGroupItem } from '@public/components/ui/radio-group' 25 - import { Card } from '@public/components/ui/card' 26 25 import { 27 26 Loader2, 28 27 Trash2, 29 - LogOut, 30 - ArrowLeft, 31 - Shield, 32 - AlertCircle, 33 - CheckCircle, 34 - Scale 28 + LogOut 35 29 } from 'lucide-react' 36 30 import Layout from '@public/layouts' 37 31 import { useUserInfo } from './hooks/useUserInfo' ··· 44 38 45 39 function Dashboard() { 46 40 // Use custom hooks 47 - const { userInfo, loading, fetchUserInfo } = useUserInfo() 41 + const { userInfo, loading, isAuthenticated, fetchUserInfo } = useUserInfo() 48 42 const { sites, sitesLoading, isSyncing, fetchSites, syncSites, deleteSite } = useSiteData() 49 43 const { 50 44 wispDomains, ··· 79 73 const [corsEnabled, setCorsEnabled] = useState(false) 80 74 const [corsOrigin, setCorsOrigin] = useState('*') 81 75 76 + // Tab state 77 + const [activeTab, setActiveTab] = useState('sites') 78 + 82 79 // Fetch initial data on mount 83 80 useEffect(() => { 84 81 fetchUserInfo() 85 82 fetchSites() 86 83 fetchDomains() 87 84 }, []) 85 + 86 + // Redirect to home if not authenticated 87 + useEffect(() => { 88 + if (isAuthenticated === false) { 89 + window.location.href = '/' 90 + } 91 + }, [isAuthenticated]) 92 + 93 + // Keyboard navigation for tabs 94 + useEffect(() => { 95 + const handleKeyDown = (e: KeyboardEvent) => { 96 + // Don't handle keyboard shortcuts if: 97 + // - A modal/dialog is open 98 + // - User is typing in an input/textarea 99 + const target = e.target as HTMLElement 100 + const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' 101 + const isModalOpen = configuringSite !== null || document.querySelector('[role="dialog"]') !== null 102 + 103 + if (isModalOpen || isTyping) { 104 + return 105 + } 106 + 107 + // Handle tab navigation with arrow keys (Left/Right only) 108 + if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { 109 + const tabs = ['sites', 'domains', 'upload', 'cli'] 110 + const currentIndex = tabs.indexOf(activeTab) 111 + 112 + if (e.key === 'ArrowLeft' && currentIndex > 0) { 113 + e.preventDefault() 114 + setActiveTab(tabs[currentIndex - 1]) 115 + } else if (e.key === 'ArrowRight' && currentIndex < tabs.length - 1) { 116 + e.preventDefault() 117 + setActiveTab(tabs[currentIndex + 1]) 118 + } 119 + } 120 + } 121 + 122 + window.addEventListener('keydown', handleKeyDown) 123 + return () => window.removeEventListener('keydown', handleKeyDown) 124 + }, [activeTab, configuringSite]) 88 125 89 126 // Handle site configuration modal 90 127 const handleConfigureSite = async (site: SiteWithDomains) => { ··· 307 344 308 345 if (loading) { 309 346 return ( 310 - <div className="w-full min-h-screen bg-background"> 347 + <div className="w-full min-h-screen bg-background font-mono"> 311 348 {/* Header Skeleton */} 312 - <header className="w-full border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 313 - <div className="max-w-6xl w-full mx-auto px-4 h-16 flex items-center justify-between"> 314 - <div className="flex items-center gap-2"> 315 - <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 316 - <span className="text-xl font-semibold text-foreground"> 317 - wisp.place 318 - </span> 349 + <header className="w-full border-b border-border/40 bg-background sticky top-0 z-50"> 350 + <div className="max-w-6xl w-full mx-auto px-6 py-6 flex items-start justify-between"> 351 + <div className="space-y-2"> 352 + <SkeletonShimmer className="h-8 w-48" /> 353 + <SkeletonShimmer className="h-4 w-64" /> 319 354 </div> 320 355 <div className="flex items-center gap-3"> 321 - <SkeletonShimmer className="h-5 w-32" /> 356 + <SkeletonShimmer className="h-4 w-32" /> 322 357 <SkeletonShimmer className="h-8 w-8 rounded" /> 323 358 </div> 324 359 </div> 325 360 </header> 326 361 327 - <div className="container mx-auto px-4 py-8 max-w-6xl w-full"> 328 - {/* Title Skeleton */} 329 - <div className="mb-8 space-y-2"> 330 - <SkeletonShimmer className="h-9 w-48" /> 331 - <SkeletonShimmer className="h-5 w-64" /> 362 + <div className="container mx-auto px-6 py-6 max-w-6xl w-full"> 363 + {/* Keyboard shortcuts skeleton */} 364 + <div className="mb-6"> 365 + <SkeletonShimmer className="h-6 w-96" /> 332 366 </div> 333 367 334 368 {/* Tabs Skeleton */} 335 369 <div className="space-y-6 w-full"> 336 - <div className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full"> 337 - <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 338 - <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 339 - <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 340 - <SkeletonShimmer className="h-8 w-1/4 mx-1" /> 370 + <div className="grid w-full grid-cols-4 border-b border-border/50"> 371 + {[...Array(4)].map((_, i) => ( 372 + <SkeletonShimmer key={i} className="h-10 w-full" /> 373 + ))} 341 374 </div> 342 375 343 376 {/* Content Skeleton */} 344 - <div className="space-y-4"> 345 - <div className="rounded-lg border border-border bg-card text-card-foreground shadow-sm"> 346 - <div className="flex flex-col space-y-1.5 p-6"> 347 - <SkeletonShimmer className="h-7 w-40" /> 348 - <SkeletonShimmer className="h-4 w-64" /> 377 + <div className="space-y-2"> 378 + <SkeletonShimmer className="h-6 w-full mb-4" /> 379 + {[...Array(3)].map((_, i) => ( 380 + <div key={i} className="border border-border/30 p-4"> 381 + <SkeletonShimmer className="h-5 w-full" /> 349 382 </div> 350 - <div className="p-6 pt-0 space-y-4"> 351 - {[...Array(3)].map((_, i) => ( 352 - <div 353 - key={i} 354 - className="flex items-center justify-between p-4 border border-border rounded-lg" 355 - > 356 - <div className="flex-1 space-y-3"> 357 - <div className="flex items-center gap-3"> 358 - <SkeletonShimmer className="h-6 w-48" /> 359 - <SkeletonShimmer className="h-5 w-16" /> 360 - </div> 361 - <SkeletonShimmer className="h-4 w-64" /> 362 - </div> 363 - <SkeletonShimmer className="h-9 w-28" /> 364 - </div> 365 - ))} 366 - </div> 367 - </div> 383 + ))} 368 384 </div> 369 385 </div> 370 386 </div> ··· 373 389 } 374 390 375 391 return ( 376 - <div className="w-full min-h-screen bg-background flex flex-col"> 392 + <div className="w-full h-screen bg-background flex flex-col font-mono overflow-hidden"> 377 393 {/* Header */} 378 - <header className="w-full border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 379 - <div className="max-w-6xl w-full mx-auto px-4 h-16 flex items-center justify-between"> 380 - <div className="flex items-center gap-2"> 381 - <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 382 - <span className="text-xl font-semibold text-foreground"> 383 - wisp.place 384 - </span> 394 + <header className="w-full border-b border-border/40 bg-background flex-shrink-0"> 395 + <div className="max-w-6xl w-full mx-auto px-6 py-6 flex items-start justify-between"> 396 + <div className="space-y-2"> 397 + <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1> 398 + <p className="text-sm text-muted-foreground"> 399 + Manage your sites and domains 400 + </p> 385 401 </div> 386 402 <div className="flex items-center gap-3"> 387 403 <span className="text-sm text-muted-foreground"> ··· 399 415 </div> 400 416 </header> 401 417 402 - <div className="container mx-auto px-4 py-8 max-w-6xl w-full"> 403 - <div className="mb-8"> 404 - <h1 className="text-3xl font-bold mb-2">Dashboard</h1> 405 - <p className="text-muted-foreground"> 406 - Manage your sites and domains 407 - </p> 408 - </div> 418 + {/* Main content area - fills remaining space */} 419 + <div className="flex-1 overflow-hidden flex flex-col"> 420 + <div className="container mx-auto px-6 py-6 max-w-6xl w-full flex flex-col h-full"> 421 + {/* Keyboard shortcuts hint */} 422 + <div className="mb-4 flex items-center gap-4 text-xs text-muted-foreground flex-shrink-0"> 423 + <div className="flex items-center gap-2"> 424 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">←</kbd> 425 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">→</kbd> 426 + <span>switch tabs</span> 427 + </div> 428 + <span>•</span> 429 + <div className="flex items-center gap-2"> 430 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">↑</kbd> 431 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">↓</kbd> 432 + <span>navigate items</span> 433 + </div> 434 + </div> 409 435 410 - <Tabs defaultValue="sites" className="space-y-6 w-full"> 411 - <TabsList className="grid w-full grid-cols-4"> 412 - <TabsTrigger value="sites">Sites</TabsTrigger> 413 - <TabsTrigger value="domains">Domains</TabsTrigger> 414 - <TabsTrigger value="upload">Upload</TabsTrigger> 415 - <TabsTrigger value="cli">CLI</TabsTrigger> 416 - </TabsList> 436 + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col flex-1 overflow-hidden"> 437 + <TabsList className="grid w-full grid-cols-4 bg-transparent border-b border-border/50 rounded-none h-auto p-0 flex-shrink-0"> 438 + <TabsTrigger 439 + value="sites" 440 + className="rounded-none border-b-2 border-transparent data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none py-3" 441 + > 442 + Sites 443 + </TabsTrigger> 444 + <TabsTrigger 445 + value="domains" 446 + className="rounded-none border-b-2 border-transparent data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none py-3" 447 + > 448 + Domains 449 + </TabsTrigger> 450 + <TabsTrigger 451 + value="upload" 452 + className="rounded-none border-b-2 border-transparent data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none py-3" 453 + > 454 + Upload 455 + </TabsTrigger> 456 + <TabsTrigger 457 + value="cli" 458 + className="rounded-none border-b-2 border-transparent data-[state=active]:border-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none py-3" 459 + > 460 + Cli 461 + </TabsTrigger> 462 + </TabsList> 417 463 418 - {/* Sites Tab */} 419 - <TabsContent value="sites"> 420 - <SitesTab 421 - sites={sites} 422 - sitesLoading={sitesLoading} 423 - isSyncing={isSyncing} 424 - userInfo={userInfo} 425 - onSyncSites={syncSites} 426 - onConfigureSite={handleConfigureSite} 427 - /> 428 - </TabsContent> 464 + {/* Sites Tab */} 465 + <TabsContent value="sites" className="flex-1 m-0 mt-4 overflow-hidden data-[state=inactive]:hidden"> 466 + <SitesTab 467 + sites={sites} 468 + sitesLoading={sitesLoading} 469 + userInfo={userInfo} 470 + onConfigureSite={handleConfigureSite} 471 + /> 472 + </TabsContent> 429 473 430 - {/* Domains Tab */} 431 - <TabsContent value="domains"> 432 - <DomainsTab 433 - wispDomains={wispDomains} 434 - customDomains={customDomains} 435 - domainsLoading={domainsLoading} 436 - verificationStatus={verificationStatus} 437 - userInfo={userInfo} 438 - onAddCustomDomain={addCustomDomain} 439 - onVerifyDomain={verifyDomain} 440 - onDeleteCustomDomain={deleteCustomDomain} 441 - onDeleteWispDomain={deleteWispDomain} 442 - onClaimWispDomain={claimWispDomain} 443 - onCheckWispAvailability={checkWispAvailability} 444 - /> 445 - </TabsContent> 474 + {/* Domains Tab */} 475 + <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"> 476 + <DomainsTab 477 + wispDomains={wispDomains} 478 + customDomains={customDomains} 479 + domainsLoading={domainsLoading} 480 + verificationStatus={verificationStatus} 481 + userInfo={userInfo} 482 + onAddCustomDomain={addCustomDomain} 483 + onVerifyDomain={verifyDomain} 484 + onDeleteCustomDomain={deleteCustomDomain} 485 + onDeleteWispDomain={deleteWispDomain} 486 + onClaimWispDomain={claimWispDomain} 487 + onCheckWispAvailability={checkWispAvailability} 488 + /> 489 + </TabsContent> 446 490 447 - {/* Upload Tab */} 448 - <TabsContent value="upload"> 449 - <UploadTab 450 - sites={sites} 451 - sitesLoading={sitesLoading} 452 - onUploadComplete={handleUploadComplete} 453 - /> 454 - </TabsContent> 491 + {/* Upload Tab */} 492 + <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"> 493 + <UploadTab 494 + sites={sites} 495 + sitesLoading={sitesLoading} 496 + onUploadComplete={handleUploadComplete} 497 + /> 498 + </TabsContent> 455 499 456 - {/* CLI Tab */} 457 - <TabsContent value="cli"> 458 - <CLITab /> 459 - </TabsContent> 460 - </Tabs> 500 + {/* CLI Tab */} 501 + <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"> 502 + <CLITab /> 503 + </TabsContent> 504 + </Tabs> 505 + </div> 461 506 </div> 462 507 463 - {/* Footer */} 464 - <footer className="border-t border-border/40 bg-muted/20 mt-auto"> 465 - <div className="container mx-auto px-4 py-8"> 466 - <div className="text-center text-sm text-muted-foreground"> 467 - <p> 468 - Built by{' '} 469 - <a 470 - href="https://bsky.app/profile/nekomimi.pet" 471 - target="_blank" 472 - rel="noopener noreferrer" 473 - className="text-accent hover:text-accent/80 transition-colors font-medium" 474 - > 475 - @nekomimi.pet 476 - </a> 477 - {' • '} 478 - Contact:{' '} 479 - <a 480 - href="mailto:contact@wisp.place" 481 - className="text-accent hover:text-accent/80 transition-colors font-medium" 482 - > 483 - contact@wisp.place 484 - </a> 485 - {' • '} 486 - Legal/DMCA:{' '} 487 - <a 488 - href="mailto:legal@wisp.place" 489 - className="text-accent hover:text-accent/80 transition-colors font-medium" 490 - > 491 - legal@wisp.place 492 - </a> 493 - </p> 494 - <p className="mt-2"> 495 - <Link 496 - to="/editor/acceptable-use" 497 - className="text-accent hover:text-accent/80 transition-colors font-medium" 498 - > 499 - Acceptable Use Policy 500 - </Link> 501 - </p> 508 + {/* Footer - always visible */} 509 + <footer className="border-t border-border/30 font-mono flex-shrink-0 bg-background"> 510 + <div className="container mx-auto px-6 py-4 max-w-6xl"> 511 + <div className="flex items-center justify-between text-xs text-muted-foreground"> 512 + <div className="flex items-center gap-6"> 513 + <span> 514 + Built by{' '} 515 + <a 516 + href="https://bsky.app/profile/nekomimi.pet" 517 + target="_blank" 518 + rel="noopener noreferrer" 519 + className="text-accent hover:text-accent/80 transition-colors" 520 + > 521 + @nekomimi.pet 522 + </a> 523 + </span> 524 + <span> 525 + Contact:{' '} 526 + <a 527 + href="mailto:contact@wisp.place" 528 + className="text-accent hover:text-accent/80 transition-colors" 529 + > 530 + contact@wisp.place 531 + </a> 532 + </span> 533 + <span> 534 + Legal:{' '} 535 + <a 536 + href="mailto:legal@wisp.place" 537 + className="text-accent hover:text-accent/80 transition-colors" 538 + > 539 + legal@wisp.place 540 + </a> 541 + </span> 542 + </div> 543 + <a 544 + href="/acceptable-use" 545 + className="text-accent hover:text-accent/80 transition-colors" 546 + > 547 + Acceptable Use Policy 548 + </a> 502 549 </div> 503 550 </div> 504 551 </footer> ··· 861 908 } 862 909 863 910 function AcceptableUsePage() { 864 - const navigate = useNavigate() 911 + // Redirect to public acceptable use page 912 + useEffect(() => { 913 + window.location.href = '/acceptable-use' 914 + }, []) 865 915 916 + // Show loading state while redirecting 866 917 return ( 867 - <div className="w-full min-h-screen bg-background flex flex-col"> 868 - {/* Header */} 869 - <header className="w-full border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 870 - <div className="max-w-6xl w-full mx-auto px-4 h-16 flex items-center justify-between"> 871 - <div className="flex items-center gap-2"> 872 - <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 873 - <span className="text-xl font-semibold text-foreground"> 874 - wisp.place 875 - </span> 876 - </div> 877 - <Button 878 - variant="ghost" 879 - size="sm" 880 - onClick={() => navigate('/editor')} 881 - > 882 - <ArrowLeft className="w-4 h-4 mr-2" /> 883 - Back to Dashboard 884 - </Button> 885 - </div> 886 - </header> 887 - 888 - {/* Hero Section */} 889 - <div className="bg-gradient-to-b from-accent/10 to-background border-b border-border/40"> 890 - <div className="container mx-auto px-4 py-16 max-w-4xl text-center"> 891 - <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent/20 mb-6"> 892 - <Shield className="w-8 h-8 text-accent" /> 893 - </div> 894 - <h1 className="text-4xl md:text-5xl font-bold mb-4">Acceptable Use Policy</h1> 895 - <div className="flex items-center justify-center gap-6 text-sm text-muted-foreground"> 896 - <div className="flex items-center gap-2"> 897 - <span className="font-medium">Effective:</span> 898 - <span>November 10, 2025</span> 899 - </div> 900 - <div className="h-4 w-px bg-border"></div> 901 - <div className="flex items-center gap-2"> 902 - <span className="font-medium">Last Updated:</span> 903 - <span>November 10, 2025</span> 904 - </div> 905 - </div> 906 - </div> 907 - </div> 908 - 909 - {/* Content */} 910 - <div className="container mx-auto px-4 py-12 max-w-4xl"> 911 - <article className="space-y-12"> 912 - {/* Our Philosophy */} 913 - <section> 914 - <h2 className="text-3xl font-bold mb-6 text-foreground">Our Philosophy</h2> 915 - <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 916 - <p> 917 - wisp.place exists to give you a corner of the internet that's truly yours—a place to create, experiment, and express yourself freely. We believe in the open web and the fundamental importance of free expression. We're not here to police your thoughts, moderate your aesthetics, or judge your taste. 918 - </p> 919 - <p> 920 - That said, we're also real people running real servers in real jurisdictions (the United States and the Netherlands), and there are legal and practical limits to what we can host. This policy aims to be as permissive as possible while keeping the lights on and staying on the right side of the law. 921 - </p> 922 - </div> 923 - </section> 924 - 925 - {/* What You Can Do */} 926 - <Card className="bg-green-500/5 border-green-500/20 p-8"> 927 - <div className="flex items-start gap-4"> 928 - <div className="flex-shrink-0"> 929 - <CheckCircle className="w-8 h-8 text-green-500" /> 930 - </div> 931 - <div className="space-y-4"> 932 - <h2 className="text-3xl font-bold text-foreground">What You Can Do</h2> 933 - <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 934 - <p> 935 - <strong className="text-green-600 dark:text-green-400">Almost anything.</strong> Seriously. Build weird art projects. Write controversial essays. Create spaces that would make corporate platforms nervous. Express unpopular opinions. Make things that are strange, provocative, uncomfortable, or just plain yours. 936 - </p> 937 - <p> 938 - We support creative and personal expression in all its forms, including adult content, political speech, counter-cultural work, and experimental projects. 939 - </p> 940 - </div> 941 - </div> 942 - </div> 943 - </Card> 944 - 945 - {/* What You Can't Do */} 946 - <section> 947 - <div className="flex items-center gap-3 mb-6"> 948 - <AlertCircle className="w-8 h-8 text-red-500" /> 949 - <h2 className="text-3xl font-bold text-foreground">What You Can't Do</h2> 950 - </div> 951 - 952 - <div className="space-y-8"> 953 - <Card className="p-6 border-2"> 954 - <h3 className="text-2xl font-semibold mb-4 text-foreground">Illegal Content</h3> 955 - <p className="text-muted-foreground mb-4"> 956 - Don't host content that's illegal in the United States or the Netherlands. This includes but isn't limited to: 957 - </p> 958 - <ul className="space-y-3 text-muted-foreground"> 959 - <li className="flex items-start gap-3"> 960 - <span className="text-red-500 mt-1">•</span> 961 - <span><strong>Child sexual abuse material (CSAM)</strong> involving real minors in any form</span> 962 - </li> 963 - <li className="flex items-start gap-3"> 964 - <span className="text-red-500 mt-1">•</span> 965 - <span><strong>Realistic or AI-generated depictions</strong> of minors in sexual contexts, including photorealistic renders, deepfakes, or AI-generated imagery</span> 966 - </li> 967 - <li className="flex items-start gap-3"> 968 - <span className="text-red-500 mt-1">•</span> 969 - <span><strong>Non-consensual intimate imagery</strong> (revenge porn, deepfakes, hidden camera footage, etc.)</span> 970 - </li> 971 - <li className="flex items-start gap-3"> 972 - <span className="text-red-500 mt-1">•</span> 973 - <span>Content depicting or facilitating human trafficking, sexual exploitation, or sexual violence</span> 974 - </li> 975 - <li className="flex items-start gap-3"> 976 - <span className="text-red-500 mt-1">•</span> 977 - <span>Instructions for manufacturing explosives, biological weapons, or other instruments designed for mass harm</span> 978 - </li> 979 - <li className="flex items-start gap-3"> 980 - <span className="text-red-500 mt-1">•</span> 981 - <span>Content that facilitates imminent violence or terrorism</span> 982 - </li> 983 - <li className="flex items-start gap-3"> 984 - <span className="text-red-500 mt-1">•</span> 985 - <span>Stolen financial information, credentials, or personal data used for fraud</span> 986 - </li> 987 - </ul> 988 - </Card> 989 - 990 - <Card className="p-6 border-2"> 991 - <h3 className="text-2xl font-semibold mb-4 text-foreground">Intellectual Property Violations</h3> 992 - <div className="space-y-4 text-muted-foreground"> 993 - <p> 994 - Don't host content that clearly violates someone else's copyright, trademark, or other intellectual property rights. We're required to respond to valid DMCA takedown notices. 995 - </p> 996 - <p> 997 - We understand that copyright law is complicated and sometimes ridiculous. We're not going to proactively scan your site or nitpick over fair use. But if we receive a legitimate legal complaint, we'll have to act on it. 998 - </p> 999 - </div> 1000 - </Card> 1001 - 1002 - <Card className="p-6 border-2 border-red-500/30 bg-red-500/5"> 1003 - <h3 className="text-2xl font-semibold mb-4 text-foreground">Hate Content</h3> 1004 - <div className="space-y-4 text-muted-foreground"> 1005 - <p> 1006 - You can express controversial ideas. You can be offensive. You can make people uncomfortable. But pure hate—content that exists solely to dehumanize, threaten, or incite violence against people based on race, ethnicity, religion, gender, sexual orientation, disability, or similar characteristics—isn't welcome here. 1007 - </p> 1008 - <p> 1009 - There's a difference between "I have deeply unpopular opinions about X" and "People like X should be eliminated." The former is protected expression. The latter isn't. 1010 - </p> 1011 - <div className="bg-background/50 border-l-4 border-red-500 p-4 rounded"> 1012 - <p className="font-medium text-foreground"> 1013 - <strong>A note on enforcement:</strong> While we're generally permissive and believe in giving people the benefit of the doubt, hate content is where we draw a hard line. I will be significantly more aggressive in moderating this type of content than anything else on this list. If your site exists primarily to spread hate or recruit people into hateful ideologies, you will be removed swiftly and without extensive appeals. This is non-negotiable. 1014 - </p> 1015 - </div> 1016 - </div> 1017 - </Card> 1018 - 1019 - <Card className="p-6 border-2"> 1020 - <h3 className="text-2xl font-semibold mb-4 text-foreground">Adult Content Guidelines</h3> 1021 - <div className="space-y-4 text-muted-foreground"> 1022 - <p> 1023 - Adult content is allowed. This includes sexually explicit material, erotica, adult artwork, and NSFW creative expression. 1024 - </p> 1025 - <p className="font-medium">However:</p> 1026 - <ul className="space-y-2"> 1027 - <li className="flex items-start gap-3"> 1028 - <span className="text-red-500 mt-1">•</span> 1029 - <span>No content involving real minors in any sexual context whatsoever</span> 1030 - </li> 1031 - <li className="flex items-start gap-3"> 1032 - <span className="text-red-500 mt-1">•</span> 1033 - <span>No photorealistic, AI-generated, or otherwise realistic depictions of minors in sexual contexts</span> 1034 - </li> 1035 - <li className="flex items-start gap-3"> 1036 - <span className="text-green-500 mt-1">•</span> 1037 - <span>Clearly stylized drawings and written fiction are permitted, provided they remain obviously non-photographic in nature</span> 1038 - </li> 1039 - <li className="flex items-start gap-3"> 1040 - <span className="text-red-500 mt-1">•</span> 1041 - <span>No non-consensual content (revenge porn, voyeurism, etc.)</span> 1042 - </li> 1043 - <li className="flex items-start gap-3"> 1044 - <span className="text-red-500 mt-1">•</span> 1045 - <span>No content depicting illegal sexual acts (bestiality, necrophilia, etc.)</span> 1046 - </li> 1047 - <li className="flex items-start gap-3"> 1048 - <span className="text-yellow-500 mt-1">•</span> 1049 - <span>Adult content should be clearly marked as such if discoverable through public directories or search</span> 1050 - </li> 1051 - </ul> 1052 - </div> 1053 - </Card> 1054 - 1055 - <Card className="p-6 border-2"> 1056 - <h3 className="text-2xl font-semibold mb-4 text-foreground">Malicious Technical Activity</h3> 1057 - <p className="text-muted-foreground mb-4">Don't use your site to:</p> 1058 - <ul className="space-y-2 text-muted-foreground"> 1059 - <li className="flex items-start gap-3"> 1060 - <span className="text-red-500 mt-1">•</span> 1061 - <span>Distribute malware, viruses, or exploits</span> 1062 - </li> 1063 - <li className="flex items-start gap-3"> 1064 - <span className="text-red-500 mt-1">•</span> 1065 - <span>Conduct phishing or social engineering attacks</span> 1066 - </li> 1067 - <li className="flex items-start gap-3"> 1068 - <span className="text-red-500 mt-1">•</span> 1069 - <span>Launch DDoS attacks or network abuse</span> 1070 - </li> 1071 - <li className="flex items-start gap-3"> 1072 - <span className="text-red-500 mt-1">•</span> 1073 - <span>Mine cryptocurrency without explicit user consent</span> 1074 - </li> 1075 - <li className="flex items-start gap-3"> 1076 - <span className="text-red-500 mt-1">•</span> 1077 - <span>Scrape, spam, or abuse other services</span> 1078 - </li> 1079 - </ul> 1080 - </Card> 1081 - </div> 1082 - </section> 1083 - 1084 - {/* Our Approach to Enforcement */} 1085 - <section> 1086 - <div className="flex items-center gap-3 mb-6"> 1087 - <Scale className="w-8 h-8 text-accent" /> 1088 - <h2 className="text-3xl font-bold text-foreground">Our Approach to Enforcement</h2> 1089 - </div> 1090 - <div className="space-y-6"> 1091 - <div className="space-y-4 text-lg leading-relaxed text-muted-foreground"> 1092 - <p> 1093 - <strong>We actively monitor for obvious violations.</strong> Not to censor your creativity or police your opinions, but to catch the clear-cut stuff that threatens the service's existence and makes this a worse place for everyone. We're looking for the blatantly illegal, the obviously harmful—the stuff that would get servers seized and communities destroyed. 1094 - </p> 1095 - <p> 1096 - We're not reading your blog posts looking for wrongthink. We're making sure this platform doesn't become a haven for the kind of content that ruins good things. 1097 - </p> 1098 - </div> 1099 - 1100 - <Card className="p-6 bg-muted/30"> 1101 - <p className="font-semibold mb-3 text-foreground">We take action when:</p> 1102 - <ol className="space-y-2 text-muted-foreground"> 1103 - <li className="flex items-start gap-3"> 1104 - <span className="font-bold text-accent">1.</span> 1105 - <span>We identify content that clearly violates this policy during routine monitoring</span> 1106 - </li> 1107 - <li className="flex items-start gap-3"> 1108 - <span className="font-bold text-accent">2.</span> 1109 - <span>We receive a valid legal complaint (DMCA, court order, etc.)</span> 1110 - </li> 1111 - <li className="flex items-start gap-3"> 1112 - <span className="font-bold text-accent">3.</span> 1113 - <span>Someone reports content that violates this policy and we can verify the violation</span> 1114 - </li> 1115 - <li className="flex items-start gap-3"> 1116 - <span className="font-bold text-accent">4.</span> 1117 - <span>Your site is causing technical problems for the service or other users</span> 1118 - </li> 1119 - </ol> 1120 - </Card> 1121 - 1122 - <Card className="p-6 bg-muted/30"> 1123 - <p className="font-semibold mb-3 text-foreground">When we do need to take action, we'll try to:</p> 1124 - <ul className="space-y-2 text-muted-foreground"> 1125 - <li className="flex items-start gap-3"> 1126 - <span className="text-accent">•</span> 1127 - <span>Contact you first when legally and practically possible</span> 1128 - </li> 1129 - <li className="flex items-start gap-3"> 1130 - <span className="text-accent">•</span> 1131 - <span>Be transparent about what's happening and why</span> 1132 - </li> 1133 - <li className="flex items-start gap-3"> 1134 - <span className="text-accent">•</span> 1135 - <span>Give you an opportunity to address the issue if appropriate</span> 1136 - </li> 1137 - </ul> 1138 - </Card> 1139 - 1140 - <p className="text-muted-foreground"> 1141 - For serious or repeated violations, we may suspend or terminate your account. 1142 - </p> 1143 - </div> 1144 - </section> 1145 - 1146 - {/* Regional Compliance */} 1147 - <Card className="p-6 bg-blue-500/5 border-blue-500/20"> 1148 - <h2 className="text-2xl font-bold mb-4 text-foreground">Regional Compliance</h2> 1149 - <p className="text-muted-foreground"> 1150 - Our servers are located in the United States and the Netherlands. Content hosted on wisp.place must comply with the laws of both jurisdictions. While we aim to provide broad creative freedom, these legal requirements are non-negotiable. 1151 - </p> 1152 - </Card> 1153 - 1154 - {/* Changes to This Policy */} 1155 - <section> 1156 - <h2 className="text-2xl font-bold mb-4 text-foreground">Changes to This Policy</h2> 1157 - <p className="text-muted-foreground"> 1158 - We may update this policy as legal requirements or service realities change. If we make significant changes, we'll notify active users. 1159 - </p> 1160 - </section> 1161 - 1162 - {/* Questions or Reports */} 1163 - <section> 1164 - <h2 className="text-2xl font-bold mb-4 text-foreground">Questions or Reports</h2> 1165 - <p className="text-muted-foreground"> 1166 - If you have questions about this policy or need to report a violation, contact us at{' '} 1167 - <a 1168 - href="mailto:contact@wisp.place" 1169 - className="text-accent hover:text-accent/80 transition-colors font-medium" 1170 - > 1171 - contact@wisp.place 1172 - </a> 1173 - . 1174 - </p> 1175 - </section> 1176 - 1177 - {/* Final Message */} 1178 - <Card className="p-8 bg-accent/10 border-accent/30 border-2"> 1179 - <p className="text-lg leading-relaxed text-foreground"> 1180 - <strong>Remember:</strong> This policy exists to keep the service running and this community healthy, not to limit your creativity. When in doubt, ask yourself: "Is this likely to get real-world authorities knocking on doors or make this place worse for everyone?" If the answer is yes, it probably doesn't belong here. Everything else? Go wild. 1181 - </p> 1182 - </Card> 1183 - </article> 1184 - </div> 1185 - 1186 - {/* Footer */} 1187 - <footer className="border-t border-border/40 bg-muted/20 mt-auto"> 1188 - <div className="container mx-auto px-4 py-8"> 1189 - <div className="text-center text-sm text-muted-foreground"> 1190 - <p> 1191 - Built by{' '} 1192 - <a 1193 - href="https://bsky.app/profile/nekomimi.pet" 1194 - target="_blank" 1195 - rel="noopener noreferrer" 1196 - className="text-accent hover:text-accent/80 transition-colors font-medium" 1197 - > 1198 - @nekomimi.pet 1199 - </a> 1200 - {' • '} 1201 - Contact:{' '} 1202 - <a 1203 - href="mailto:contact@wisp.place" 1204 - className="text-accent hover:text-accent/80 transition-colors font-medium" 1205 - > 1206 - contact@wisp.place 1207 - </a> 1208 - {' • '} 1209 - Legal/DMCA:{' '} 1210 - <a 1211 - href="mailto:legal@wisp.place" 1212 - className="text-accent hover:text-accent/80 transition-colors font-medium" 1213 - > 1214 - legal@wisp.place 1215 - </a> 1216 - </p> 1217 - <p className="mt-2"> 1218 - <Link 1219 - to="/editor" 1220 - className="text-accent hover:text-accent/80 transition-colors font-medium" 1221 - > 1222 - Back to Dashboard 1223 - </Link> 1224 - </p> 1225 - </div> 1226 - </div> 1227 - </footer> 918 + <div className="w-full min-h-screen bg-background flex items-center justify-center"> 919 + <Loader2 className="w-8 h-8 animate-spin text-muted-foreground" /> 1228 920 </div> 1229 921 ) 1230 922 }
+10
apps/main-app/public/editor/hooks/useUserInfo.ts
··· 8 8 export function useUserInfo() { 9 9 const [userInfo, setUserInfo] = useState<UserInfo | null>(null) 10 10 const [loading, setLoading] = useState(true) 11 + const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null) 11 12 12 13 const fetchUserInfo = async () => { 13 14 try { 14 15 const response = await fetch('/api/user/info') 16 + if (!response.ok) { 17 + // Not authenticated or other error 18 + setIsAuthenticated(false) 19 + setUserInfo(null) 20 + return 21 + } 15 22 const data = await response.json() 16 23 setUserInfo(data) 24 + setIsAuthenticated(true) 17 25 } catch (err) { 18 26 console.error('Failed to fetch user info:', err) 27 + setIsAuthenticated(false) 19 28 } finally { 20 29 setLoading(false) 21 30 } ··· 24 33 return { 25 34 userInfo, 26 35 loading, 36 + isAuthenticated, 27 37 fetchUserInfo 28 38 } 29 39 }
+345 -165
apps/main-app/public/editor/tabs/SitesTab.tsx
··· 1 - import { 2 - Card, 3 - CardContent, 4 - CardDescription, 5 - CardHeader, 6 - CardTitle 7 - } from '@public/components/ui/card' 1 + import { useState, useEffect, useRef, useCallback } from 'react' 8 2 import { Button } from '@public/components/ui/button' 9 3 import { Badge } from '@public/components/ui/badge' 10 4 import { SkeletonShimmer } from '@public/components/ui/skeleton' 11 5 import { 12 6 Globe, 13 7 ExternalLink, 14 - CheckCircle2, 15 - AlertCircle, 16 - Loader2, 17 - RefreshCw, 18 - Settings 8 + ChevronRight, 9 + ChevronDown, 10 + Settings as SettingsIcon, 11 + Trash2 19 12 } from 'lucide-react' 20 13 import type { SiteWithDomains } from '../hooks/useSiteData' 21 14 import type { UserInfo } from '../hooks/useUserInfo' ··· 23 16 interface SitesTabProps { 24 17 sites: SiteWithDomains[] 25 18 sitesLoading: boolean 26 - isSyncing: boolean 27 19 userInfo: UserInfo | null 28 - onSyncSites: () => Promise<void> 29 20 onConfigureSite: (site: SiteWithDomains) => void 30 21 } 31 22 23 + // Helper to generate unique site key 24 + const getSiteKey = (site: SiteWithDomains) => `${site.did}-${site.rkey}` 25 + 26 + // Sort domains: custom first, then wisp 27 + const getSortedDomains = (site: SiteWithDomains) => { 28 + if (!site.domains || site.domains.length === 0) return [] 29 + return [...site.domains].sort((a, b) => { 30 + if (a.type === 'custom' && b.type === 'wisp') return -1 31 + if (a.type === 'wisp' && b.type === 'custom') return 1 32 + return 0 33 + }) 34 + } 35 + 36 + // Keyboard shortcut badge component 37 + const Kbd = ({ children }: { children: React.ReactNode }) => ( 38 + <kbd className="px-2 py-1 bg-muted/50 rounded border border-border/50">{children}</kbd> 39 + ) 40 + 32 41 export function SitesTab({ 33 42 sites, 34 43 sitesLoading, 35 - isSyncing, 36 44 userInfo, 37 - onSyncSites, 38 45 onConfigureSite 39 46 }: SitesTabProps) { 40 - const getSiteUrl = (site: SiteWithDomains) => { 41 - // Use the first mapped domain if available 42 - if (site.domains && site.domains.length > 0) { 43 - return `https://${site.domains[0].domain}` 44 - } 47 + // State: only one site can be expanded at a time (null = none expanded) 48 + const [expandedSiteKey, setExpandedSiteKey] = useState<string | null>(null) 49 + const [focusedIndex, setFocusedIndex] = useState(0) 45 50 46 - // Default fallback URL - use handle instead of DID 51 + // Refs 52 + const containerRef = useRef<HTMLDivElement>(null) 53 + const siteRefs = useRef<(HTMLDivElement | null)[]>([]) 54 + const scrollContainerRef = useRef<HTMLDivElement>(null) 55 + 56 + // URL helpers 57 + const getSiteUrl = useCallback((site: SiteWithDomains) => { 58 + const sortedDomains = getSortedDomains(site) 59 + if (sortedDomains.length > 0) { 60 + return `https://${sortedDomains[0].domain}` 61 + } 47 62 if (!userInfo) return '#' 48 63 return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}` 49 - } 64 + }, [userInfo]) 50 65 51 - const getSiteDomainName = (site: SiteWithDomains) => { 52 - // Return the first domain if available 53 - if (site.domains && site.domains.length > 0) { 54 - return site.domains[0].domain 66 + const getSiteDomainName = useCallback((site: SiteWithDomains) => { 67 + const sortedDomains = getSortedDomains(site) 68 + if (sortedDomains.length > 0) { 69 + return sortedDomains[0].domain 55 70 } 56 - 57 - // Use handle instead of DID for display 58 71 if (!userInfo) return `sites.wisp.place/.../${site.rkey}` 59 72 return `sites.wisp.place/${userInfo.handle}/${site.rkey}` 73 + }, [userInfo]) 74 + 75 + // Toggle expand - auto-closes other sites 76 + const toggleExpanded = useCallback((siteKey: string) => { 77 + setExpandedSiteKey(prev => prev === siteKey ? null : siteKey) 78 + }, []) 79 + 80 + // Auto-focus container when sites load 81 + useEffect(() => { 82 + if (sites.length > 0 && containerRef.current) { 83 + const timer = setTimeout(() => containerRef.current?.focus(), 100) 84 + return () => clearTimeout(timer) 85 + } 86 + }, [sites.length]) 87 + 88 + // Watch for dialog close and refocus container 89 + useEffect(() => { 90 + let wasDialogOpen = document.querySelector('[role="dialog"]') !== null 91 + 92 + const observer = new MutationObserver(() => { 93 + const isDialogOpen = document.querySelector('[role="dialog"]') !== null 94 + 95 + if (wasDialogOpen && !isDialogOpen) { 96 + // Dialog just closed, refocus the container 97 + setTimeout(() => containerRef.current?.focus(), 50) 98 + } 99 + 100 + wasDialogOpen = isDialogOpen 101 + }) 102 + 103 + observer.observe(document.body, { childList: true, subtree: true }) 104 + 105 + return () => observer.disconnect() 106 + }, []) 107 + 108 + // Scroll focused item into view 109 + useEffect(() => { 110 + const element = siteRefs.current[focusedIndex] 111 + if (element && scrollContainerRef.current) { 112 + const container = scrollContainerRef.current 113 + const elementRect = element.getBoundingClientRect() 114 + const containerRect = container.getBoundingClientRect() 115 + 116 + const isOutOfView = 117 + elementRect.bottom > containerRect.bottom - 50 || 118 + elementRect.top < containerRect.top + 50 119 + 120 + if (isOutOfView) { 121 + element.scrollIntoView({ behavior: 'smooth', block: 'center' }) 122 + } 123 + } 124 + }, [focusedIndex]) 125 + 126 + // Keyboard navigation 127 + useEffect(() => { 128 + const handleKeyDown = (e: KeyboardEvent) => { 129 + const target = e.target as HTMLElement 130 + const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' 131 + const isDialogOpen = document.querySelector('[role="dialog"]') !== null 132 + const hasFocus = containerRef.current?.contains(document.activeElement) 133 + 134 + if (isTyping || isDialogOpen || sites.length === 0 || !hasFocus) return 135 + 136 + const currentSite = sites[focusedIndex] 137 + const currentKey = currentSite ? getSiteKey(currentSite) : null 138 + const isExpanded = currentKey === expandedSiteKey 139 + 140 + switch (e.key) { 141 + case 'ArrowUp': 142 + e.preventDefault() 143 + setFocusedIndex(prev => Math.max(0, prev - 1)) 144 + break 145 + case 'ArrowDown': 146 + e.preventDefault() 147 + setFocusedIndex(prev => Math.min(sites.length - 1, prev + 1)) 148 + break 149 + case 'Enter': 150 + case ' ': 151 + e.preventDefault() 152 + if (currentKey) toggleExpanded(currentKey) 153 + break 154 + case 'o': 155 + if (isExpanded && currentSite) { 156 + e.preventDefault() 157 + window.open(getSiteUrl(currentSite), '_blank') 158 + } 159 + break 160 + case 'c': 161 + if (isExpanded && currentSite) { 162 + e.preventDefault() 163 + onConfigureSite(currentSite) 164 + } 165 + break 166 + case 'd': 167 + if (isExpanded && currentSite) { 168 + e.preventDefault() 169 + onConfigureSite(currentSite) 170 + } 171 + break 172 + } 173 + } 174 + 175 + window.addEventListener('keydown', handleKeyDown) 176 + return () => window.removeEventListener('keydown', handleKeyDown) 177 + }, [sites, focusedIndex, expandedSiteKey, toggleExpanded, getSiteUrl, onConfigureSite]) 178 + 179 + // Loading state 180 + if (sitesLoading) { 181 + return ( 182 + <div className="h-full flex flex-col border border-border/30 bg-card/50 font-mono"> 183 + <div className="p-4 pb-3 border-b border-border/30"> 184 + <SkeletonShimmer className="h-4 w-64" /> 185 + </div> 186 + <div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-2"> 187 + {Array.from({ length: 5 }).map((_, i) => ( 188 + <div key={i} className="p-4 border border-border/30"> 189 + <SkeletonShimmer className="h-5 w-full" /> 190 + </div> 191 + ))} 192 + </div> 193 + </div> 194 + ) 195 + } 196 + 197 + // Empty state 198 + if (sites.length === 0) { 199 + return ( 200 + <div className="h-full flex flex-col border border-border/30 bg-card/50 font-mono"> 201 + <div className="p-4 pb-3 border-b border-border/30 text-xs text-muted-foreground"> 202 + No keyboard shortcuts available 203 + </div> 204 + <div className="flex-1 flex items-center justify-center text-muted-foreground"> 205 + No sites yet. Upload your first site! 206 + </div> 207 + </div> 208 + ) 60 209 } 61 210 62 211 return ( 63 - <div className="space-y-4 min-h-[400px]"> 64 - <Card> 65 - <CardHeader> 66 - <div className="flex items-center justify-between"> 67 - <div> 68 - <CardTitle>Your Sites</CardTitle> 69 - <CardDescription> 70 - View and manage all your deployed sites 71 - </CardDescription> 72 - </div> 73 - {userInfo && ( 74 - <Button 75 - variant="outline" 76 - size="sm" 77 - asChild 78 - > 79 - <a 80 - href={`https://pdsls.dev/at://${userInfo.did}/place.wisp.fs`} 81 - target="_blank" 82 - rel="noopener noreferrer" 83 - > 84 - <ExternalLink className="w-4 h-4 mr-2" /> 85 - View in PDS 86 - </a> 87 - </Button> 88 - )} 89 - </div> 90 - </CardHeader> 91 - <CardContent className="space-y-4"> 92 - {sitesLoading ? ( 93 - <div className="space-y-4"> 94 - {[...Array(3)].map((_, i) => ( 95 - <div 96 - key={i} 97 - className="flex items-center justify-between p-4 border border-border rounded-lg" 98 - > 99 - <div className="flex-1 space-y-3"> 100 - <div className="flex items-center gap-3"> 101 - <SkeletonShimmer className="h-6 w-48" /> 102 - <SkeletonShimmer className="h-5 w-16" /> 103 - </div> 104 - <SkeletonShimmer className="h-4 w-64" /> 105 - </div> 106 - <SkeletonShimmer className="h-9 w-28" /> 107 - </div> 108 - ))} 109 - </div> 110 - ) : sites.length === 0 ? ( 111 - <div className="text-center py-8 text-muted-foreground"> 112 - <p>No sites yet. Upload your first site!</p> 113 - </div> 114 - ) : ( 115 - sites.map((site) => ( 116 - <div 117 - key={`${site.did}-${site.rkey}`} 118 - className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors" 212 + <div 213 + ref={containerRef} 214 + tabIndex={0} 215 + className="h-full flex flex-col border border-border/30 bg-card/50 font-mono outline-none" 216 + onClick={() => containerRef.current?.focus()} 217 + > 218 + {/* Keyboard hints */} 219 + <div className="flex items-center gap-4 text-xs text-muted-foreground p-4 pb-3 border-b border-border/30 flex-shrink-0"> 220 + <div className="flex items-center gap-2"> 221 + <Kbd>↑</Kbd><Kbd>↓</Kbd> 222 + <span>navigate</span> 223 + </div> 224 + <span>•</span> 225 + <div className="flex items-center gap-2"> 226 + <Kbd>Enter</Kbd> 227 + <span>expand</span> 228 + </div> 229 + <span>•</span> 230 + <span>When expanded:</span> 231 + <div className="flex items-center gap-2"> 232 + <Kbd>o</Kbd><span>open</span> 233 + </div> 234 + <span>•</span> 235 + <div className="flex items-center gap-2"> 236 + <Kbd>c</Kbd><span>configure</span> 237 + </div> 238 + <span>•</span> 239 + <div className="flex items-center gap-2"> 240 + <Kbd>d</Kbd><span className="text-red-400">delete</span> 241 + </div> 242 + </div> 243 + 244 + {/* Sites list */} 245 + <div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto p-4 space-y-2"> 246 + {sites.map((site, index) => { 247 + const siteKey = getSiteKey(site) 248 + const isExpanded = expandedSiteKey === siteKey 249 + const isFocused = index === focusedIndex 250 + const siteName = site.display_name || site.rkey 251 + const sortedDomains = getSortedDomains(site) 252 + 253 + return ( 254 + <div 255 + key={siteKey} 256 + ref={el => { siteRefs.current[index] = el }} 257 + className={`border transition-colors ${ 258 + isFocused ? 'border-accent bg-accent/10' : 'border-border/30 hover:bg-muted/20' 259 + }`} 260 + > 261 + {/* Site header */} 262 + <button 263 + onClick={() => toggleExpanded(siteKey)} 264 + className="w-full flex items-center gap-3 p-4 text-left" 119 265 > 120 - <div className="flex-1"> 121 - <div className="flex items-center gap-3 mb-2"> 122 - <h3 className="font-semibold text-lg"> 123 - {site.display_name || site.rkey} 124 - </h3> 125 - <Badge 126 - variant="secondary" 127 - className="text-xs" 128 - > 129 - active 266 + {isExpanded ? ( 267 + <ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" /> 268 + ) : ( 269 + <ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" /> 270 + )} 271 + <span className="font-semibold flex-1">{siteName}</span> 272 + <div className="flex items-center gap-2"> 273 + <span className="text-xs text-muted-foreground"> 274 + {getSiteDomainName(site)} 275 + </span> 276 + {site.domains && site.domains.length > 1 && ( 277 + <Badge variant="outline" className="text-[10px]"> 278 + +{site.domains.length - 1} 130 279 </Badge> 131 - </div> 280 + )} 281 + </div> 282 + <Badge variant="outline" className="text-[10px] text-accent border-accent/50"> 283 + [active] 284 + </Badge> 285 + </button> 132 286 133 - {/* Display all mapped domains */} 134 - {site.domains && site.domains.length > 0 ? ( 135 - <div className="space-y-1"> 136 - {site.domains.map((domainInfo, idx) => ( 137 - <div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2"> 138 - <a 139 - href={`https://${domainInfo.domain}`} 140 - target="_blank" 141 - rel="noopener noreferrer" 142 - className="text-sm text-accent hover:text-accent/80 flex items-center gap-1" 143 - > 144 - <Globe className="w-3 h-3" /> 145 - {domainInfo.domain} 146 - <ExternalLink className="w-3 h-3" /> 147 - </a> 148 - <Badge 149 - variant={domainInfo.type === 'wisp' ? 'default' : 'outline'} 150 - className="text-xs" 151 - > 152 - {domainInfo.type} 153 - </Badge> 154 - {domainInfo.type === 'custom' && ( 287 + {/* Expanded content */} 288 + {isExpanded && ( 289 + <div className="px-4 pb-4 pl-11 space-y-4 border-l-2 border-accent/50 ml-4"> 290 + {/* Domains */} 291 + <div> 292 + <p className="text-xs text-muted-foreground uppercase tracking-wider mb-2"> 293 + {sortedDomains.length > 0 ? 'DOMAINS:' : 'DEFAULT URL:'} 294 + </p> 295 + {sortedDomains.length > 0 ? ( 296 + <div className="space-y-2"> 297 + {sortedDomains.map((domain, idx) => ( 298 + <div key={`${domain.domain}-${idx}`} className="flex items-center gap-2"> 299 + <a 300 + href={`https://${domain.domain}`} 301 + target="_blank" 302 + rel="noopener noreferrer" 303 + className="text-sm text-accent hover:text-accent/80 flex items-center gap-2" 304 + > 305 + <Globe className="w-3 h-3" /> 306 + {domain.domain} 307 + </a> 155 308 <Badge 156 - variant={domainInfo.verified ? 'default' : 'secondary'} 157 - className="text-xs" 309 + variant={domain.type === 'wisp' ? 'secondary' : 'outline'} 310 + className="text-[10px]" 158 311 > 159 - {domainInfo.verified ? ( 160 - <> 161 - <CheckCircle2 className="w-3 h-3 mr-1" /> 162 - verified 163 - </> 164 - ) : ( 165 - <> 166 - <AlertCircle className="w-3 h-3 mr-1" /> 167 - pending 168 - </> 169 - )} 312 + {domain.type} 170 313 </Badge> 171 - )} 172 - </div> 173 - ))} 314 + {domain.type === 'custom' && domain.verified !== undefined && ( 315 + <Badge 316 + variant={domain.verified ? 'default' : 'secondary'} 317 + className="text-[10px]" 318 + > 319 + {domain.verified ? '✓ verified' : '⏳ pending'} 320 + </Badge> 321 + )} 322 + </div> 323 + ))} 324 + </div> 325 + ) : ( 326 + <a 327 + href={getSiteUrl(site)} 328 + target="_blank" 329 + rel="noopener noreferrer" 330 + className="text-sm text-accent hover:text-accent/80 flex items-center gap-2" 331 + > 332 + <Globe className="w-4 h-4" /> 333 + {getSiteDomainName(site)} 334 + </a> 335 + )} 336 + </div> 337 + 338 + {/* Actions */} 339 + <div> 340 + <p className="text-xs text-muted-foreground uppercase tracking-wider mb-3"> 341 + ACTIONS: 342 + </p> 343 + <div className="flex flex-wrap gap-3"> 344 + <Button 345 + variant="outline" 346 + size="sm" 347 + className="font-mono text-xs" 348 + onClick={() => window.open(getSiteUrl(site), '_blank')} 349 + > 350 + <ExternalLink className="w-3 h-3 mr-2" /> 351 + Open 352 + <kbd className="ml-2 px-1.5 py-0.5 bg-muted/50 rounded text-[10px]">o</kbd> 353 + </Button> 354 + <Button 355 + variant="outline" 356 + size="sm" 357 + className="font-mono text-xs" 358 + onClick={() => onConfigureSite(site)} 359 + > 360 + <SettingsIcon className="w-3 h-3 mr-2" /> 361 + Configure 362 + <kbd className="ml-2 px-1.5 py-0.5 bg-muted/50 rounded text-[10px]">c</kbd> 363 + </Button> 364 + <Button 365 + variant="outline" 366 + size="sm" 367 + className="font-mono text-xs text-red-400 hover:text-red-500 hover:border-red-400/50" 368 + onClick={() => onConfigureSite(site)} 369 + > 370 + <Trash2 className="w-3 h-3 mr-2" /> 371 + Delete 372 + <kbd className="ml-2 px-1.5 py-0.5 bg-muted/50 rounded text-[10px]">d</kbd> 373 + </Button> 174 374 </div> 175 - ) : ( 375 + </div> 376 + 377 + {/* View in PDS link */} 378 + {userInfo && ( 176 379 <a 177 - href={getSiteUrl(site)} 380 + href={`https://pdsls.dev/at://${userInfo.did}/place.wisp.fs/${site.rkey}`} 178 381 target="_blank" 179 382 rel="noopener noreferrer" 180 - className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1" 383 + className="flex items-center gap-2 text-xs text-muted-foreground hover:text-accent transition-colors" 181 384 > 182 - {getSiteDomainName(site)} 183 - <ExternalLink className="w-3 h-3" /> 385 + → View in PDS 184 386 </a> 185 387 )} 186 388 </div> 187 - <Button 188 - variant="outline" 189 - size="sm" 190 - onClick={() => onConfigureSite(site)} 191 - > 192 - <Settings className="w-4 h-4 mr-2" /> 193 - Configure 194 - </Button> 195 - </div> 196 - )) 197 - )} 198 - </CardContent> 199 - </Card> 200 - 201 - <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50"> 202 - <div className="flex items-start gap-2"> 203 - <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" /> 204 - <div className="flex-1 space-y-1"> 205 - <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400"> 206 - Note about sites.wisp.place URLs 207 - </p> 208 - <p className="text-xs text-muted-foreground"> 209 - Complex sites hosted on <code className="px-1 py-0.5 bg-background rounded text-xs">sites.wisp.place</code> may have broken assets if they use absolute paths (e.g., <code className="px-1 py-0.5 bg-background rounded text-xs">/folder/script.js</code>) in CSS or JavaScript files. While HTML paths are automatically rewritten, CSS and JS files are served as-is. For best results, use a wisp.place subdomain or custom domain, or ensure your site uses relative paths. 210 - </p> 211 - </div> 212 - </div> 389 + )} 390 + </div> 391 + ) 392 + })} 213 393 </div> 214 394 </div> 215 395 )
+131 -5
apps/main-app/public/editor/tabs/UploadTab.tsx
··· 55 55 const [uploadedCount, setUploadedCount] = useState(0) 56 56 const [fileProgressList, setFileProgressList] = useState<FileProgress[]>([]) 57 57 const [showFileProgress, setShowFileProgress] = useState(false) 58 + const [isDragging, setIsDragging] = useState(false) 59 + 60 + // Ref for the drop zone 61 + const dropZoneRef = useRef<HTMLDivElement>(null) 58 62 59 63 // Keep SSE connection alive across tab switches 60 64 const eventSourceRef = useRef<EventSource | null>(null) ··· 78 82 const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { 79 83 if (e.target.files && e.target.files.length > 0) { 80 84 setSelectedFiles(e.target.files) 85 + } 86 + } 87 + 88 + // Recursively read all files from a directory entry 89 + const readDirectory = async (entry: FileSystemDirectoryEntry): Promise<File[]> => { 90 + const files: File[] = [] 91 + const reader = entry.createReader() 92 + 93 + const readEntries = (): Promise<FileSystemEntry[]> => { 94 + return new Promise((resolve, reject) => { 95 + reader.readEntries(resolve, reject) 96 + }) 97 + } 98 + 99 + const getFile = (fileEntry: FileSystemFileEntry): Promise<File> => { 100 + return new Promise((resolve, reject) => { 101 + fileEntry.file(resolve, reject) 102 + }) 103 + } 104 + 105 + // Read all entries (readEntries may need to be called multiple times) 106 + let entries: FileSystemEntry[] = [] 107 + let batch: FileSystemEntry[] 108 + do { 109 + batch = await readEntries() 110 + entries = entries.concat(batch) 111 + } while (batch.length > 0) 112 + 113 + for (const childEntry of entries) { 114 + if (childEntry.isFile) { 115 + const file = await getFile(childEntry as FileSystemFileEntry) 116 + // Create a new File with the full path 117 + const fullPath = childEntry.fullPath.startsWith('/') 118 + ? childEntry.fullPath.slice(1) 119 + : childEntry.fullPath 120 + const fileWithPath = new File([file], fullPath, { type: file.type }) 121 + files.push(fileWithPath) 122 + } else if (childEntry.isDirectory) { 123 + const subFiles = await readDirectory(childEntry as FileSystemDirectoryEntry) 124 + files.push(...subFiles) 125 + } 126 + } 127 + 128 + return files 129 + } 130 + 131 + // Handle dropped items (files or directories) 132 + const handleDrop = async (e: React.DragEvent<HTMLDivElement>) => { 133 + e.preventDefault() 134 + e.stopPropagation() 135 + setIsDragging(false) 136 + 137 + if (isUploading) return 138 + 139 + const items = e.dataTransfer.items 140 + if (!items || items.length === 0) return 141 + 142 + const allFiles: File[] = [] 143 + 144 + // Process all dropped items 145 + for (let i = 0; i < items.length; i++) { 146 + const item = items[i] 147 + const entry = item.webkitGetAsEntry() 148 + 149 + if (entry) { 150 + if (entry.isFile) { 151 + const file = await new Promise<File>((resolve, reject) => { 152 + (entry as FileSystemFileEntry).file(resolve, reject) 153 + }) 154 + allFiles.push(file) 155 + } else if (entry.isDirectory) { 156 + const dirFiles = await readDirectory(entry as FileSystemDirectoryEntry) 157 + allFiles.push(...dirFiles) 158 + } 159 + } 160 + } 161 + 162 + if (allFiles.length > 0) { 163 + // Create a DataTransfer to build a FileList 164 + const dataTransfer = new DataTransfer() 165 + allFiles.forEach(file => dataTransfer.items.add(file)) 166 + setSelectedFiles(dataTransfer.files) 167 + } 168 + } 169 + 170 + const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => { 171 + e.preventDefault() 172 + e.stopPropagation() 173 + if (!isUploading) { 174 + setIsDragging(true) 175 + } 176 + } 177 + 178 + const handleDragEnter = (e: React.DragEvent<HTMLDivElement>) => { 179 + e.preventDefault() 180 + e.stopPropagation() 181 + if (!isUploading) { 182 + setIsDragging(true) 183 + } 184 + } 185 + 186 + const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => { 187 + e.preventDefault() 188 + e.stopPropagation() 189 + // Only set isDragging to false if we're leaving the drop zone entirely 190 + if (dropZoneRef.current && !dropZoneRef.current.contains(e.relatedTarget as Node)) { 191 + setIsDragging(false) 81 192 } 82 193 } 83 194 ··· 395 506 </div> 396 507 397 508 <div className="grid md:grid-cols-2 gap-4"> 398 - <Card className="border-2 border-dashed hover:border-accent transition-colors cursor-pointer"> 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 + > 399 521 <CardContent className="flex flex-col items-center justify-center p-8 text-center"> 400 - <Upload className="w-12 h-12 text-muted-foreground mb-4" /> 522 + <Upload className={`w-12 h-12 mb-4 transition-colors ${ 523 + isDragging ? 'text-accent' : 'text-muted-foreground' 524 + }`} /> 401 525 <h3 className="font-semibold mb-2"> 402 526 Upload Folder 403 527 </h3> 404 528 <p className="text-sm text-muted-foreground mb-4"> 405 - Drag and drop or click to upload your 406 - static site files 529 + {isDragging 530 + ? 'Drop your files here...' 531 + : 'Drag and drop or click to upload your static site files' 532 + } 407 533 </p> 408 534 <input 409 535 type="file" ··· 429 555 </Button> 430 556 </label> 431 557 {selectedFiles && selectedFiles.length > 0 && ( 432 - <p className="text-sm text-muted-foreground mt-3"> 558 + <p className="text-sm text-accent mt-3 font-medium"> 433 559 {selectedFiles.length} files selected 434 560 </p> 435 561 )}
+12
apps/main-app/public/landingpage.html
··· 677 677 </style> 678 678 </head> 679 679 <body> 680 + <script> 681 + // Check if user is already signed in and redirect to editor 682 + fetch('/api/user/info') 683 + .then(response => { 684 + if (response.ok) { 685 + window.location.href = '/editor'; 686 + } 687 + }) 688 + .catch(() => { 689 + // Not signed in, stay on landing page 690 + }); 691 + </script> 680 692 <header> 681 693 <div class="header-inner"> 682 694 <a href="/" class="logo">wisp.place</a>
+1 -1
apps/main-app/src/lib/slingshot-handle-resolver.ts
··· 10 10 * Uses: https://slingshot.wisp.place/xrpc/com.atproto.identity.resolveHandle 11 11 */ 12 12 export class SlingshotHandleResolver implements HandleResolver { 13 - private readonly endpoint = 'https://slingshot.wisp.place/xrpc/com.atproto.identity.resolveHandle'; 13 + private readonly endpoint = 'https://slingshot.microcosm.blue/xrpc/com.atproto.identity.resolveHandle'; 14 14 15 15 async resolve(handle: string, options?: ResolveHandleOptions): Promise<ResolvedHandle> { 16 16 try {