A fullstack app for indexing standard.site documents
1import { useEffect, useState } from "react";
2
3// API base URL - empty for same-origin (local dev), or set via env var for production
4const API_URL = "https://atfeeds-api.stevedsimkins.workers.dev";
5
6interface BskyPostRef {
7 uri: string;
8 cid: string;
9}
10
11interface Publication {
12 url: string;
13 name: string;
14 description?: string;
15 iconCid?: string;
16 iconUrl?: string;
17}
18
19interface Document {
20 uri: string;
21 did: string;
22 rkey: string;
23 title: string;
24 description?: string;
25 path?: string;
26 site?: string;
27 content?: {
28 $type: string;
29 markdown?: string;
30 };
31 textContent?: string;
32 coverImageCid?: string;
33 coverImageUrl?: string;
34 bskyPostRef?: BskyPostRef;
35 tags?: string[];
36 publishedAt?: string;
37 updatedAt?: string;
38 publication?: Publication;
39 viewUrl?: string;
40 pdsEndpoint?: string;
41}
42
43interface FeedResponse {
44 count: number;
45 limit: number;
46 offset: number;
47 documents: Document[];
48}
49
50function App() {
51 const [documents, setDocuments] = useState<Document[]>([]);
52 const [loading, setLoading] = useState(true);
53 const [error, setError] = useState<string | null>(null);
54
55 const fetchFeed = async () => {
56 setLoading(true);
57 setError(null);
58 try {
59 const response = await fetch(`${API_URL}/feed?limit=100`);
60 if (!response.ok) {
61 throw new Error("Failed to fetch feed");
62 }
63 const data: FeedResponse = await response.json();
64 setDocuments(data.documents);
65 } catch (err) {
66 setError(err instanceof Error ? err.message : "Unknown error");
67 } finally {
68 setLoading(false);
69 }
70 };
71
72 useEffect(() => {
73 fetchFeed();
74 }, []);
75
76 const formatDate = (dateString?: string) => {
77 if (!dateString) return "Unknown date";
78 const date = new Date(dateString);
79 const now = new Date();
80 const diff = now.getTime() - date.getTime();
81 const minutes = Math.floor(diff / 60000);
82 const hours = Math.floor(diff / 3600000);
83 const days = Math.floor(diff / 86400000);
84
85 if (minutes < 1) return "just now";
86 if (minutes < 60) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
87 if (hours < 24) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
88 if (days < 7) return `${days} day${days > 1 ? "s" : ""} ago`;
89
90 return date.toLocaleDateString("en-US", {
91 year: "numeric",
92 month: "long",
93 day: "numeric",
94 });
95 };
96
97 const truncateText = (text?: string, maxLength: number = 200) => {
98 if (!text) return "";
99 if (text.length <= maxLength) return text;
100 return text.slice(0, maxLength) + "...";
101 };
102
103 const getDescription = (doc: Document) => {
104 return doc.description || doc.textContent || "";
105 };
106
107 return (
108 <div className="window" style={{ width: "100%", maxWidth: "900px" }}>
109 <div className="title-bar">
110 <div className="title-bar-text">
111 Docs.surf - Microsoft Internet Explorer
112 </div>
113 <div className="title-bar-controls">
114 <button aria-label="Minimize" />
115 <button aria-label="Maximize" />
116 <button aria-label="Close" />
117 </div>
118 </div>
119
120 {/* IE Chrome Container */}
121 <div style={{ margin: "0 2px" }}>
122 {/* Menu Bar */}
123 <div
124 style={{
125 display: "flex",
126 justifyContent: "flex-start",
127 padding: "2px 0",
128 backgroundColor: "#ece9d8",
129 borderBottom: "1px solid #aca899",
130 fontSize: "11px",
131 }}
132 >
133 {["File", "Edit", "View", "Favorites", "Tools", "Help"].map(
134 (item) => (
135 <span
136 key={item}
137 style={{
138 padding: "2px 8px",
139 cursor: "pointer",
140 }}
141 >
142 {item}
143 </span>
144 ),
145 )}
146 </div>
147
148 {/* Toolbar */}
149 <div
150 className="ie-toolbar"
151 style={{
152 display: "flex",
153 alignItems: "center",
154 gap: "0",
155 padding: "3px 2px",
156 backgroundColor: "#ece9d8",
157 borderBottom: "1px solid #aca899",
158 overflow: "hidden",
159 }}
160 >
161 {/* Back button */}
162 <div
163 style={{
164 display: "flex",
165 alignItems: "center",
166 gap: "2px",
167 padding: "0 6px",
168 cursor: "pointer",
169 }}
170 >
171 <img
172 src="/windows-icons/Back.png"
173 alt="Back"
174 style={{ width: "22px", height: "22px" }}
175 />
176 <span style={{ fontSize: "11px" }}>Back</span>
177 <span style={{ fontSize: "8px", marginLeft: "2px" }}>▼</span>
178 </div>
179
180 {/* Forward button */}
181 <div
182 style={{
183 display: "flex",
184 alignItems: "center",
185 padding: "0 4px",
186 cursor: "pointer",
187 }}
188 >
189 <img
190 src="/windows-icons/Forward.png"
191 alt="Forward"
192 style={{ width: "22px", height: "22px" }}
193 />
194 </div>
195
196 <div
197 style={{
198 width: "1px",
199 height: "22px",
200 backgroundColor: "#aca899",
201 margin: "0 4px",
202 }}
203 />
204
205 {/* Stop */}
206 <div
207 style={{
208 padding: "0 4px",
209 cursor: "pointer",
210 }}
211 >
212 <img
213 src="/windows-icons/Stop.png"
214 alt="Stop"
215 style={{ width: "22px", height: "22px" }}
216 />
217 </div>
218
219 {/* Refresh */}
220 <div
221 onClick={fetchFeed}
222 style={{
223 padding: "0 4px",
224 cursor: "pointer",
225 }}
226 >
227 <img
228 src="/windows-icons/IE Refresh.png"
229 alt="Refresh"
230 style={{ width: "22px", height: "22px" }}
231 />
232 </div>
233
234 {/* Home */}
235 <a
236 href="https://stevedylan.dev"
237 target="_blank"
238 rel="noreferrer"
239 style={{
240 padding: "0 4px",
241 cursor: "pointer",
242 }}
243 >
244 <img
245 src="/windows-icons/IE Home.png"
246 alt="Home"
247 style={{ width: "22px", height: "22px" }}
248 />
249 </a>
250
251 <div
252 style={{
253 width: "1px",
254 height: "22px",
255 backgroundColor: "#aca899",
256 margin: "0 4px",
257 }}
258 />
259
260 {/* Search */}
261 <div
262 style={{
263 display: "flex",
264 alignItems: "center",
265 gap: "3px",
266 padding: "0 6px",
267 cursor: "pointer",
268 }}
269 >
270 <img
271 src="/windows-icons/Search.png"
272 alt="Search"
273 style={{ width: "22px", height: "22px" }}
274 />
275 <span style={{ fontSize: "11px" }}>Search</span>
276 </div>
277
278 {/* Favorites */}
279 <div
280 style={{
281 display: "flex",
282 alignItems: "center",
283 gap: "3px",
284 padding: "0 6px",
285 cursor: "pointer",
286 }}
287 >
288 <img
289 src="/windows-icons/Favorites.png"
290 alt="Favorites"
291 style={{ width: "22px", height: "22px" }}
292 />
293 <span style={{ fontSize: "11px" }}>Favorites</span>
294 </div>
295
296 <div
297 className="ie-secondary"
298 style={{
299 width: "1px",
300 height: "22px",
301 backgroundColor: "#aca899",
302 margin: "0 4px",
303 }}
304 />
305
306 {/* Mail */}
307 <div
308 className="ie-secondary"
309 style={{
310 display: "flex",
311 alignItems: "center",
312 padding: "0 4px",
313 cursor: "pointer",
314 }}
315 >
316 <img
317 src="/windows-icons/Email.png"
318 alt="Mail"
319 style={{ width: "22px", height: "22px" }}
320 />
321 <span style={{ fontSize: "8px", marginLeft: "1px" }}>▼</span>
322 </div>
323
324 {/* Print */}
325 <div
326 className="ie-secondary"
327 style={{
328 padding: "0 4px",
329 cursor: "pointer",
330 }}
331 >
332 <img
333 src="/windows-icons/Printer.png"
334 alt="Print"
335 style={{ width: "22px", height: "22px" }}
336 />
337 </div>
338 </div>
339
340 {/* Address Bar */}
341 <div
342 style={{
343 display: "flex",
344 alignItems: "center",
345 gap: "4px",
346 padding: "2px 4px",
347 backgroundColor: "#ece9d8",
348 borderBottom: "1px solid #aca899",
349 }}
350 >
351 <span style={{ fontSize: "11px" }}>Address</span>
352 <div
353 style={{
354 flex: 1,
355 display: "flex",
356 alignItems: "center",
357 backgroundColor: "white",
358 border: "1px solid #7f9db9",
359 padding: "2px 4px",
360 }}
361 >
362 <img
363 src="/windows-icons/Internet Explorer 6.png"
364 alt=""
365 style={{ width: "16px", height: "16px", marginRight: "4px" }}
366 />
367 <span style={{ flex: 1, fontSize: "12px", color: "#000" }}>
368 https://docs.surf
369 </span>
370 <span
371 style={{
372 fontSize: "10px",
373 color: "#666",
374 padding: "0 4px",
375 cursor: "pointer",
376 }}
377 >
378 ▼
379 </span>
380 </div>
381 <button
382 style={{
383 display: "flex",
384 alignItems: "center",
385 gap: "3px",
386 padding: "2px 12px",
387 fontSize: "11px",
388 minWidth: "50px",
389 }}
390 >
391 <img
392 src="/windows-icons/Go.png"
393 alt=""
394 style={{ width: "16px", height: "16px" }}
395 />
396 Go
397 </button>
398 <span style={{ fontSize: "11px", marginLeft: "4px" }}>Links</span>
399 <span style={{ fontSize: "10px" }}>»</span>
400 </div>
401 </div>
402
403 <div className="window-body" style={{ margin: 0, padding: "4px 6px" }}>
404 {loading && <p style={{ textAlign: "center" }}>Searching...</p>}
405
406 {error && (
407 <div
408 style={{
409 padding: "10px",
410 background: "#ffefef",
411 border: "1px solid #ff0000",
412 }}
413 >
414 <p>Error: {error}</p>
415 </div>
416 )}
417
418 {!loading && !error && (
419 <div
420 className="feed"
421 style={{
422 maxHeight: "70vh",
423 overflowY: "auto",
424 paddingRight: "5px",
425 }}
426 >
427 <div
428 style={{
429 background: "#ffffff",
430 padding: 0,
431 margin: 0,
432 }}
433 >
434 <div
435 style={{
436 display: "flex",
437 alignItems: "center",
438 justifyContent: "space-between",
439 padding: "1rem",
440 }}
441 >
442 <h3 style={{ margin: 0 }}>Welcome to Docs.surf! 🏄</h3>
443 <a
444 href="https://api.docs.surf/rss.xml"
445 target="_blank"
446 rel="noopener noreferrer"
447 style={{
448 flexShrink: 0,
449 borderRadius: "8px",
450 padding: "4px",
451 display: "inline-block",
452 }}
453 >
454 <img
455 src="/rss.svg"
456 alt="RSS"
457 width="24"
458 height="24"
459 style={{
460 opacity: 0.8,
461 borderRadius: "4px",
462 }}
463 />
464 </a>
465 </div>
466 <details
467 style={{
468 fontSize: "14px",
469 padding: "0 1rem 1rem 1rem",
470 }}
471 >
472 <summary style={{ cursor: "pointer" }}>What is this?</summary>
473 <div style={{ paddingTop: "0.5rem", fontSize: "14px" }}>
474 <p>
475 Docs.surf is a{" "}
476 <a
477 href="https://standard.site"
478 target="_blank"
479 rel="noreferrer"
480 >
481 Standard.site
482 </a>{" "}
483 aggregator, pulling all valid Publications and Documents
484 into a single chronological feed. You can think of it like
485 RSS, but there's no manual collection. It's all powered by{" "}
486 <a
487 href="https://atproto.com"
488 target="_blank"
489 rel="noreferrer"
490 >
491 ATProto
492 </a>
493 , a new protocol to power connections across the web.
494 </p>
495 <p>
496 Source code can be found at{" "}
497 <a
498 href="https://tangled.org/stevedylan.dev/docs.surf/"
499 target="_blank"
500 rel="noreferrer"
501 >
502 tangled.org/stevedylandev/docs.surf
503 </a>
504 </p>
505 </div>
506 </details>
507 </div>
508
509 {documents.map((doc, index) => (
510 <div
511 key={doc.uri}
512 style={{
513 display: "flex",
514 gap: "12px",
515 padding: "16px",
516 borderBottom:
517 index < documents.length - 1 ? "1px solid #e0e0e0" : "none",
518 backgroundColor: "#ffffff",
519 position: "relative",
520 }}
521 >
522 {/* Thumbnail on the left */}
523 <div style={{ flexShrink: 0 }}>
524 {doc.coverImageUrl || doc.publication?.iconUrl ? (
525 <img
526 src={doc.coverImageUrl || doc.publication?.iconUrl}
527 alt={doc.title}
528 style={{
529 width: "88px",
530 height: "88px",
531 objectFit: "scale-down",
532 border: "1px solid #d0d0d0",
533 }}
534 />
535 ) : (
536 <img
537 src="/clouds.png"
538 alt="Default"
539 style={{
540 width: "88px",
541 height: "88px",
542 objectFit: "cover",
543 border: "1px solid #d0d0d0",
544 }}
545 />
546 )}
547 </div>
548
549 {/* Content on the right */}
550 <div style={{ flex: 1, minWidth: 0 }}>
551 {/* Title */}
552 <h3
553 style={{
554 margin: "0 0 8px 0",
555 fontSize: "15px",
556 fontWeight: "normal",
557 color: "#333",
558 lineHeight: "1.3",
559 }}
560 >
561 {doc.viewUrl ? (
562 <a
563 href={doc.viewUrl}
564 target="_blank"
565 rel="noopener noreferrer"
566 style={{
567 color: "#333",
568 textDecoration: "none",
569 }}
570 >
571 {doc.title}
572 </a>
573 ) : (
574 doc.title
575 )}
576 </h3>
577
578 {/* Description */}
579 {getDescription(doc) && (
580 <p
581 style={{
582 margin: "0 0 8px 0",
583 fontSize: "12px",
584 color: "#666",
585 lineHeight: "1.4",
586 }}
587 >
588 {truncateText(getDescription(doc), 150)}
589 </p>
590 )}
591
592 {/* Publication name and timestamp */}
593 <div
594 style={{
595 display: "flex",
596 alignItems: "center",
597 justifyContent: "space-between",
598 fontSize: "12px",
599 }}
600 >
601 <a
602 href={doc.publication?.url}
603 target="_blank"
604 rel="noreferrer"
605 style={{
606 color: "#7aaa3c",
607 fontWeight: "bold",
608 }}
609 >
610 {doc.publication?.name || "Unknown"}
611 </a>
612 <a
613 href={`https://pdsls.dev/${doc.uri}`}
614 target="_blank"
615 rel="noreferrer"
616 style={{
617 color: "#999",
618 }}
619 >
620 {formatDate(doc.publishedAt)}
621 </a>
622 </div>
623 </div>
624
625 {/* RSS icon on the far right */}
626 {/*<div style={{ flexShrink: 0 }}>
627 <svg
628 width="24"
629 height="24"
630 viewBox="0 0 24 24"
631 fill="none"
632 xmlns="http://www.w3.org/2000/svg"
633 style={{ opacity: 0.6 }}
634 >
635 <circle cx="6" cy="18" r="2" fill="#ff6600" />
636 <path
637 d="M4 4c9.941 0 18 8.059 18 18"
638 stroke="#ff6600"
639 strokeWidth="2"
640 fill="none"
641 />
642 <path
643 d="M4 11c6.075 0 11 4.925 11 11"
644 stroke="#ff6600"
645 strokeWidth="2"
646 fill="none"
647 />
648 </svg>
649 </div>*/}
650 </div>
651 ))}
652 {documents.length === 0 && <p>No documents found.</p>}
653 </div>
654 )}
655 </div>
656 <div className="status-bar">
657 <p className="status-bar-field">Done</p>
658 </div>
659 </div>
660 );
661}
662
663export default App;