An in-browser wisp.place site explorer
at main 220 lines 7.8 kB view raw
1/** 2 * ResolverUI component 3 * 4 * Main landing page for the wisp client with handle/DID input form. 5 */ 6 7import { useState, useEffect } from 'react'; 8import { useATProtoResolver } from '../hooks/useATProtoResolver'; 9import { useSitesFetcher } from '../hooks/useManifestFetcher'; 10import { InlineLoading } from './LoadingState'; 11import { InlineError } from './ErrorDisplay'; 12 13export interface ResolverUIProps { 14 initialHandle?: string; 15 onLoad?: (handle: string, siteRkey: string, siteName: string) => void; 16} 17 18export function ResolverUI({ initialHandle = '', onLoad }: ResolverUIProps) { 19 const [handleInput, setHandleInput] = useState(initialHandle); 20 const [debouncedInput, setDebouncedInput] = useState(initialHandle); 21 const [selectedSite, setSelectedSite] = useState<{ rkey: string; name: string } | null>(null); 22 23 // Debounce input to avoid excessive resolution requests 24 useEffect(() => { 25 const timer = setTimeout(() => { 26 setDebouncedInput(handleInput.trim()); 27 }, 500); 28 29 return () => clearTimeout(timer); 30 }, [handleInput]); 31 32 // Resolve handle/DID 33 const resolverState = useATProtoResolver(debouncedInput || null); 34 35 // Fetch available sites when resolution completes 36 const sitesState = useSitesFetcher( 37 resolverState.data?.pdsUrl || null, 38 resolverState.data?.did || null 39 ); 40 41 // Handle form submission 42 const handleSubmit = (e: React.FormEvent) => { 43 e.preventDefault(); 44 45 if (!handleInput.trim()) { 46 return; 47 } 48 49 if (!resolverState.data || resolverState.error) { 50 return; 51 } 52 53 if (!sitesState.data || sitesState.data.length === 0) { 54 return; 55 } 56 57 // Use selected site, or first site if none selected 58 const siteInfo = selectedSite || { 59 rkey: sitesState.data[0].rkey, 60 name: sitesState.data[0].site, 61 }; 62 63 const handle = resolverState.data.handle || handleInput.trim(); 64 65 // Trigger load callback with rkey (for fetching) and name (for URL) 66 onLoad?.(handle, siteInfo.rkey, siteInfo.name); 67 }; 68 69 // Handle input change 70 const handleInputChange = (value: string) => { 71 setHandleInput(value); 72 setSelectedSite(null); 73 }; 74 75 // Handle site selection 76 const handleSiteSelect = (rkey: string, name: string) => { 77 setSelectedSite({ rkey, name }); 78 }; 79 80 // Check if can submit 81 const canSubmit = 82 handleInput.trim() && 83 resolverState.data && 84 !resolverState.loading && 85 !resolverState.error && 86 sitesState.data && 87 sitesState.data.length > 0; 88 89 return ( 90 <div className="min-h-screen bg-gradient-to-br from-sky-50 via-blue-50 to-indigo-50"> 91 <div className="max-w-2xl mx-auto px-4 py-12"> 92 {/* Header */} 93 <div className="text-center mb-8"> 94 <h1 className="text-4xl font-bold text-gray-900 mb-2"> 95 wisp.place explorer 96 </h1> 97 <p className="text-gray-600"> 98 Browse websites from the PDS (unofficial) 99 </p> 100 </div> 101 102 {/* Main card */} 103 <div className="bg-white rounded-2xl shadow-xl p-8 mb-6"> 104 <form onSubmit={handleSubmit}> 105 {/* Input field */} 106 <div className="mb-4"> 107 <label htmlFor="handle" className="block text-sm font-medium text-gray-700 mb-2"> 108 Handle or DID 109 </label> 110 <input 111 id="handle" 112 type="text" 113 value={handleInput} 114 onChange={(e) => handleInputChange(e.target.value)} 115 placeholder="e.g., mp9.ca, did:plc:abc..." 116 className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-lg" 117 autoFocus 118 /> 119 </div> 120 121 {/* Resolution status */} 122 {handleInput && ( 123 <div className="mb-4"> 124 {resolverState.loading && ( 125 <div className="flex items-center gap-2 text-sm text-gray-500"> 126 <InlineLoading message="Resolving..." size="sm" /> 127 </div> 128 )} 129 130 {resolverState.error && ( 131 <InlineError error={resolverState.error} /> 132 )} 133 134 {resolverState.data && ( 135 <div className="bg-sky-50 border border-sky-200 rounded-lg p-3"> 136 <div className="flex items-center justify-between"> 137 <div className="flex-1 min-w-0"> 138 <p className="text-sm font-medium text-sky-900 truncate"> 139 {resolverState.data.handle || 'DID'} 140 </p> 141 <p className="text-xs text-sky-700 font-mono truncate"> 142 {resolverState.data.did} 143 </p> 144 <p className="text-xs text-sky-600 truncate"> 145 PDS: {resolverState.data.pdsUrl} 146 </p> 147 </div> 148 <div className="ml-2 flex-shrink-0"> 149 <span className="text-green-500"></span> 150 </div> 151 </div> 152 </div> 153 )} 154 </div> 155 )} 156 157 {/* Site selector (if multiple sites) */} 158 {resolverState.data && sitesState.data && sitesState.data.length > 0 && ( 159 <div className="mb-4"> 160 <label className="block text-sm font-medium text-gray-700 mb-2"> 161 Select Site ({sitesState.data.length} available) 162 </label> 163 <div className="grid grid-cols-1 sm:grid-cols-2 gap-2"> 164 {sitesState.data.map((site) => ( 165 <button 166 key={site.rkey} 167 type="button" 168 onClick={() => handleSiteSelect(site.rkey, site.site)} 169 className={`px-3 py-2 rounded-lg border text-left transition-colors ${ 170 selectedSite?.name === site.site 171 ? 'bg-sky-100 border-sky-500 text-sky-900' 172 : 'bg-white border-gray-200 hover:bg-gray-50 text-gray-700' 173 }`} 174 > 175 <div className="flex items-center justify-between"> 176 <span className="font-medium text-sm">{site.site}</span> 177 {selectedSite?.name === site.site && ( 178 <span className="text-sky-600"></span> 179 )} 180 </div> 181 {site.fileCount && ( 182 <p className="text-xs text-gray-500 mt-1"> 183 {site.fileCount} files 184 </p> 185 )} 186 </button> 187 ))} 188 </div> 189 </div> 190 )} 191 192 {/* Submit button */} 193 <button 194 type="submit" 195 disabled={!canSubmit} 196 className="w-full px-6 py-3 bg-sky-500 text-white rounded-lg font-medium hover:bg-sky-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-lg" 197 > 198 Load Site 199 </button> 200 </form> 201 </div> 202 203 {/* Footer */} 204 <div className="mt-8 text-center text-sm text-gray-500"> 205 <p> 206 For more information on wisp.place sites, see {' '} 207 <a 208 href="https://wisp.place" 209 target="_blank" 210 rel="noopener noreferrer" 211 className="text-sky-600 hover:text-sky-700" 212 > 213 wisp.place 214 </a>. This explorer is unaffiliated. 215 </p> 216 </div> 217 </div> 218 </div> 219 ); 220}