Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1import {
2 Card,
3 CardContent,
4 CardDescription,
5 CardHeader,
6 CardTitle
7} from '@public/components/ui/card'
8import { Button } from '@public/components/ui/button'
9import { Badge } from '@public/components/ui/badge'
10import { SkeletonShimmer } from '@public/components/ui/skeleton'
11import {
12 Globe,
13 ExternalLink,
14 CheckCircle2,
15 AlertCircle,
16 Loader2,
17 RefreshCw,
18 Settings
19} from 'lucide-react'
20import type { SiteWithDomains } from '../hooks/useSiteData'
21import type { UserInfo } from '../hooks/useUserInfo'
22
23interface SitesTabProps {
24 sites: SiteWithDomains[]
25 sitesLoading: boolean
26 isSyncing: boolean
27 userInfo: UserInfo | null
28 onSyncSites: () => Promise<void>
29 onConfigureSite: (site: SiteWithDomains) => void
30}
31
32export function SitesTab({
33 sites,
34 sitesLoading,
35 isSyncing,
36 userInfo,
37 onSyncSites,
38 onConfigureSite
39}: 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 }
45
46 // Default fallback URL - use handle instead of DID
47 if (!userInfo) return '#'
48 return `https://sites.wisp.place/${userInfo.handle}/${site.rkey}`
49 }
50
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
55 }
56
57 // Use handle instead of DID for display
58 if (!userInfo) return `sites.wisp.place/.../${site.rkey}`
59 return `sites.wisp.place/${userInfo.handle}/${site.rkey}`
60 }
61
62 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 <Button
74 variant="outline"
75 size="sm"
76 onClick={onSyncSites}
77 disabled={isSyncing || sitesLoading}
78 >
79 <RefreshCw
80 className={`w-4 h-4 mr-2 ${isSyncing ? 'animate-spin' : ''}`}
81 />
82 Sync from PDS
83 </Button>
84 </div>
85 </CardHeader>
86 <CardContent className="space-y-4">
87 {sitesLoading ? (
88 <div className="space-y-4">
89 {[...Array(3)].map((_, i) => (
90 <div
91 key={i}
92 className="flex items-center justify-between p-4 border border-border rounded-lg"
93 >
94 <div className="flex-1 space-y-3">
95 <div className="flex items-center gap-3">
96 <SkeletonShimmer className="h-6 w-48" />
97 <SkeletonShimmer className="h-5 w-16" />
98 </div>
99 <SkeletonShimmer className="h-4 w-64" />
100 </div>
101 <SkeletonShimmer className="h-9 w-28" />
102 </div>
103 ))}
104 </div>
105 ) : sites.length === 0 ? (
106 <div className="text-center py-8 text-muted-foreground">
107 <p>No sites yet. Upload your first site!</p>
108 </div>
109 ) : (
110 sites.map((site) => (
111 <div
112 key={`${site.did}-${site.rkey}`}
113 className="flex items-center justify-between p-4 border border-border rounded-lg hover:bg-muted/50 transition-colors"
114 >
115 <div className="flex-1">
116 <div className="flex items-center gap-3 mb-2">
117 <h3 className="font-semibold text-lg">
118 {site.display_name || site.rkey}
119 </h3>
120 <Badge
121 variant="secondary"
122 className="text-xs"
123 >
124 active
125 </Badge>
126 </div>
127
128 {/* Display all mapped domains */}
129 {site.domains && site.domains.length > 0 ? (
130 <div className="space-y-1">
131 {site.domains.map((domainInfo, idx) => (
132 <div key={`${domainInfo.domain}-${idx}`} className="flex items-center gap-2">
133 <a
134 href={`https://${domainInfo.domain}`}
135 target="_blank"
136 rel="noopener noreferrer"
137 className="text-sm text-accent hover:text-accent/80 flex items-center gap-1"
138 >
139 <Globe className="w-3 h-3" />
140 {domainInfo.domain}
141 <ExternalLink className="w-3 h-3" />
142 </a>
143 <Badge
144 variant={domainInfo.type === 'wisp' ? 'default' : 'outline'}
145 className="text-xs"
146 >
147 {domainInfo.type}
148 </Badge>
149 {domainInfo.type === 'custom' && (
150 <Badge
151 variant={domainInfo.verified ? 'default' : 'secondary'}
152 className="text-xs"
153 >
154 {domainInfo.verified ? (
155 <>
156 <CheckCircle2 className="w-3 h-3 mr-1" />
157 verified
158 </>
159 ) : (
160 <>
161 <AlertCircle className="w-3 h-3 mr-1" />
162 pending
163 </>
164 )}
165 </Badge>
166 )}
167 </div>
168 ))}
169 </div>
170 ) : (
171 <a
172 href={getSiteUrl(site)}
173 target="_blank"
174 rel="noopener noreferrer"
175 className="text-sm text-muted-foreground hover:text-accent flex items-center gap-1"
176 >
177 {getSiteDomainName(site)}
178 <ExternalLink className="w-3 h-3" />
179 </a>
180 )}
181 </div>
182 <Button
183 variant="outline"
184 size="sm"
185 onClick={() => onConfigureSite(site)}
186 >
187 <Settings className="w-4 h-4 mr-2" />
188 Configure
189 </Button>
190 </div>
191 ))
192 )}
193 </CardContent>
194 </Card>
195
196 <div className="p-4 bg-muted/30 rounded-lg border-l-4 border-yellow-500/50">
197 <div className="flex items-start gap-2">
198 <AlertCircle className="w-4 h-4 text-yellow-600 dark:text-yellow-400 mt-0.5 shrink-0" />
199 <div className="flex-1 space-y-1">
200 <p className="text-xs font-semibold text-yellow-600 dark:text-yellow-400">
201 Note about sites.wisp.place URLs
202 </p>
203 <p className="text-xs text-muted-foreground">
204 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.
205 </p>
206 </div>
207 </div>
208 </div>
209 </div>
210 )
211}