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