An in-browser wisp.place site explorer
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}