An in-browser wisp.place site explorer
at main 279 lines 8.9 kB view raw
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;