An in-browser wisp.place site explorer
1/**
2 * Main App component
3 *
4 * Entry point for the Wisp Client application.
5 *
6 * Routes:
7 * - /: Resolver UI (landing page)
8 * - /wisp/{did}/{siteName}/{path}: Handled by service worker - React renders minimal component
9 */
10
11import { useState, useEffect } from 'react';
12import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
13import { ResolverUI, SiteRendererSW, ServiceWorkerDebug } from './components';
14import { useATProtoResolver } from './hooks/useATProtoResolver';
15import { useSitesFetcher, useManifestFetcherManual } from './hooks/useManifestFetcher';
16
17function ResolverWrapper() {
18 const location = useLocation();
19 const [handle, setHandle] = useState<string>('');
20 const [siteRkey, setSiteRkey] = useState<string>('');
21 const [siteName, setSiteName] = useState<string>('');
22 const [loading, setLoading] = useState<'idle' | 'resolving' | 'fetching'>('idle');
23 const [error, setError] = useState<string | null>(null);
24
25 // Check for handle in query params
26 useEffect(() => {
27 const params = new URLSearchParams(location.search);
28 const handleParam = params.get('handle');
29 if (handleParam) setHandle(handleParam);
30 }, [location.search]);
31
32 // Resolve handle when provided
33 const resolver = useATProtoResolver(handle || null);
34
35 // Fetch sites list when resolved (for validation)
36 useSitesFetcher(
37 resolver.data?.pdsUrl || null,
38 resolver.data?.did || null
39 );
40
41 // Fetch manifest when site is selected
42 const manifestState = useManifestFetcherManual(
43 resolver.data?.pdsUrl || null,
44 resolver.data?.did || null,
45 siteRkey || undefined
46 );
47
48 // Handle loading a site
49 const handleLoad = async (loadedHandle: string, loadedSiteRkey: string, loadedSiteName: string) => {
50 setHandle(loadedHandle);
51 setSiteRkey(loadedSiteRkey);
52 setSiteName(loadedSiteName);
53 setLoading('resolving');
54
55 // The resolver and manifest fetchers will automatically trigger
56 // We'll wait for them and then navigate
57 };
58
59 // Handle navigate to wisp
60 useEffect(() => {
61 if (loading === 'resolving' && resolver.data && !resolver.loading) {
62 if (resolver.error) {
63 setError(resolver.error);
64 setLoading('idle');
65 return;
66 }
67 setLoading('fetching');
68 }
69
70 if (loading === 'fetching' && manifestState.data && !manifestState.loading) {
71 if (manifestState.error) {
72 setError(manifestState.error);
73 setLoading('idle');
74 return;
75 }
76
77 // Success - trigger navigation via SiteRendererSW
78 }
79 }, [loading, resolver, manifestState]);
80
81 // Handle back/cancel
82 const handleBack = () => {
83 setHandle('');
84 setSiteRkey('');
85 setSiteName('');
86 setLoading('idle');
87 setError(null);
88 };
89
90 // Show resolver UI
91 if (!handle || loading === 'idle') {
92 return (
93 <ResolverUI
94 initialHandle={handle}
95 onLoad={handleLoad}
96 />
97 );
98 }
99
100 // Show loading state
101 if (resolver.loading || manifestState.loading) {
102 const stage = resolver.loading ? 'resolving' : 'fetchingManifest';
103 const message = resolver.loading
104 ? `Resolving ${handle}...`
105 : manifestState.loading
106 ? 'Fetching site manifest...'
107 : 'Loading...';
108
109 return (
110 <div className="min-h-screen bg-gradient-to-br from-sky-50 to-blue-100 flex items-center justify-center p-8">
111 <div className="max-w-md mx-auto">
112 {stage === 'resolving' && (
113 <div className="bg-white rounded-lg shadow-lg p-8">
114 <div className="flex items-center justify-center mb-4">
115 <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-sky-500"></div>
116 </div>
117 <p className="text-center text-gray-600">{message}</p>
118 <button
119 onClick={handleBack}
120 className="mt-6 w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-medium"
121 >
122 Cancel
123 </button>
124 </div>
125 )}
126 {stage === 'fetchingManifest' && (
127 <div className="bg-white rounded-lg shadow-lg p-8">
128 <div className="flex items-center justify-center mb-4">
129 <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-sky-500"></div>
130 </div>
131 <p className="text-center text-gray-600 mb-2">{message}</p>
132 {manifestState.recordCount !== undefined && (
133 <p className="text-center text-sm text-gray-500">
134 Found {manifestState.recordCount} files
135 </p>
136 )}
137 <button
138 onClick={handleBack}
139 className="mt-6 w-full px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors font-medium"
140 >
141 Cancel
142 </button>
143 </div>
144 )}
145 </div>
146 </div>
147 );
148 }
149
150 // Show error state
151 if (error || resolver.error || manifestState.error) {
152 const errorMessage = error || resolver.error || manifestState.error;
153 return (
154 <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
155 <div className="max-w-2xl w-full">
156 <div className="bg-white rounded-lg shadow-lg p-8">
157 <div className="flex items-center gap-3 mb-4">
158 <div className="bg-red-100 p-2 rounded-full">
159 <svg
160 className="w-6 h-6 text-red-600"
161 fill="none"
162 stroke="currentColor"
163 viewBox="0 0 24 24"
164 >
165 <path
166 strokeLinecap="round"
167 strokeLinejoin="round"
168 strokeWidth={2}
169 d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
170 />
171 </svg>
172 </div>
173 <h2 className="text-xl font-bold text-gray-900">Error</h2>
174 </div>
175 <p className="text-gray-600 mb-6">{errorMessage}</p>
176 <div className="flex gap-3">
177 <button
178 onClick={handleBack}
179 className="flex-1 px-4 py-2 bg-sky-500 text-white rounded-lg hover:bg-sky-600 transition-colors font-medium"
180 >
181 Try Again
182 </button>
183 </div>
184 </div>
185 </div>
186 </div>
187 );
188 }
189
190 // Render the site using service worker
191 if (resolver.data && manifestState.data) {
192 return (
193 <SiteRendererSW
194 pdsUrl={resolver.data.pdsUrl}
195 did={resolver.data.did}
196 handle={handle}
197 siteName={siteName}
198 manifest={manifestState.data}
199 onBack={handleBack}
200 />
201 );
202 }
203
204 return null;
205}
206
207/**
208 * SiteRouteWrapper - Minimal wrapper for /wisp/* routes
209 *
210 * The service worker handles the actual content serving.
211 * This component renders nothing (or minimal UI) so the service worker
212 * can intercept and serve the site content.
213 */
214function SiteRouteWrapper() {
215 const location = useLocation();
216
217 useEffect(() => {
218 console.log('[App] Site route:', location.pathname);
219
220 // Check if service worker has a manifest loaded
221 // If not, the user might have bookmarked a /wisp/ URL
222 // We should redirect to the resolver
223 const checkSW = async () => {
224 if (navigator.serviceWorker && navigator.serviceWorker.controller) {
225 // Send a message to check status
226 const channel = new MessageChannel();
227 const timeout = setTimeout(() => {
228 channel.port1.close();
229 // No response, redirect to resolver
230 window.location.href = '/';
231 }, 1000);
232
233 channel.port1.onmessage = (event) => {
234 clearTimeout(timeout);
235 channel.port1.close();
236
237 if (!event.data.hasManifest) {
238 // No manifest loaded, redirect to resolver
239 console.log('[App] No manifest in SW, redirecting to resolver');
240 window.location.href = '/';
241 }
242 };
243
244 navigator.serviceWorker.controller.postMessage(
245 { type: 'GET_STATUS' },
246 [channel.port2]
247 );
248 }
249 };
250
251 checkSW();
252 }, [location.pathname]);
253
254 // Render nothing - service worker serves the content
255 return null;
256}
257
258function App() {
259 return (
260 <>
261 <Routes>
262 {/* Resolver UI - handles / */}
263 <Route path="/" element={<ResolverWrapper />} />
264
265 {/* Wisp routes - handled by service worker */}
266 {/* Pattern: /wisp/{did}/{siteName}/* */}
267 <Route path="/wisp/:did/:siteName/*" element={<SiteRouteWrapper />} />
268
269 {/* Catch-all - redirect to resolver */}
270 <Route path="*" element={<Navigate to="/" replace />} />
271 </Routes>
272
273 {/* Debug component - only shows in development */}
274 <ServiceWorkerDebug />
275 </>
276 );
277}
278
279export default App;