packages/client/src/App.tsx 14.9 K raw
1
import { useEffect, useState } from "react";
2
3
// API base URL - empty for same-origin (local dev), or set via env var for production
4
const API_URL = "https://atfeeds-api.stevedsimkins.workers.dev";
5
6
interface BskyPostRef {
7
	uri: string;
8
	cid: string;
9
}
10
11
interface Publication {
12
	url: string;
13
	name: string;
14
	description?: string;
15
	iconCid?: string;
16
	iconUrl?: string;
17
}
18
19
interface 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
43
interface FeedResponse {
44
	count: number;
45
	limit: number;
46
	offset: number;
47
	documents: Document[];
48
}
49
50
function 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
				<div
405
					className="feed"
406
					style={{
407
						height: "70vh",
408
						overflowY: "auto",
409
						paddingRight: "5px",
410
					}}
411
				>
412
					{loading && (
413
						<p style={{ textAlign: "center", padding: "1rem" }}>
414
							Searching...
415
						</p>
416
					)}
417
418
					{error && (
419
						<div
420
							style={{
421
								padding: "10px",
422
								background: "#ffefef",
423
								border: "1px solid #ff0000",
424
							}}
425
						>
426
							<p>Error: {error}</p>
427
						</div>
428
					)}
429
430
					{!loading && !error && (
431
						<>
432
						<div
433
							style={{
434
								background: "#ffffff",
435
								padding: 0,
436
								margin: 0,
437
							}}
438
						>
439
							<div
440
								style={{
441
									display: "flex",
442
									alignItems: "center",
443
									justifyContent: "space-between",
444
									padding: "1rem",
445
								}}
446
							>
447
								<h3 style={{ margin: 0 }}>Welcome to Docs.surf! 🏄</h3>
448
								<a
449
									href="https://api.docs.surf/rss.xml"
450
									target="_blank"
451
									rel="noopener noreferrer"
452
									style={{
453
										flexShrink: 0,
454
										borderRadius: "8px",
455
										padding: "4px",
456
										display: "inline-block",
457
									}}
458
								>
459
									<img
460
										src="/rss.svg"
461
										alt="RSS"
462
										width="24"
463
										height="24"
464
										style={{
465
											opacity: 0.8,
466
											borderRadius: "4px",
467
										}}
468
									/>
469
								</a>
470
							</div>
471
							<details
472
								style={{
473
									fontSize: "14px",
474
									padding: "0 1rem 1rem 1rem",
475
								}}
476
							>
477
								<summary style={{ cursor: "pointer" }}>What is this?</summary>
478
								<div style={{ paddingTop: "0.5rem", fontSize: "14px" }}>
479
									<p>
480
										Docs.surf is a{" "}
481
										<a
482
											href="https://standard.site"
483
											target="_blank"
484
											rel="noreferrer"
485
										>
486
											Standard.site
487
										</a>{" "}
488
										aggregator, pulling all valid Publications and Documents
489
										into a single chronological feed. You can think of it like
490
										RSS, but there's no manual collection. It's all powered by{" "}
491
										<a
492
											href="https://atproto.com"
493
											target="_blank"
494
											rel="noreferrer"
495
										>
496
											atproto
497
										</a>
498
										, a new protocol to power connections across the web.
499
									</p>
500
									<p>
501
										Source code can be found at{" "}
502
										<a
503
											href="https://tangled.org/stevedylan.dev/docs.surf/"
504
											target="_blank"
505
											rel="noreferrer"
506
										>
507
											tangled.org/stevedylandev/docs.surf
508
										</a>
509
									</p>
510
								</div>
511
							</details>
512
						</div>
513
514
						{documents.map((doc, index) => (
515
							<div
516
								key={doc.uri}
517
								style={{
518
									display: "flex",
519
									gap: "12px",
520
									padding: "16px",
521
									borderBottom:
522
										index < documents.length - 1 ? "1px solid #e0e0e0" : "none",
523
									backgroundColor: "#ffffff",
524
									position: "relative",
525
								}}
526
							>
527
								{/* Thumbnail on the left */}
528
								<div style={{ flexShrink: 0 }}>
529
									{doc.coverImageUrl || doc.publication?.iconUrl ? (
530
										<img
531
											src={doc.coverImageUrl || doc.publication?.iconUrl}
532
											alt={doc.title}
533
											style={{
534
												width: "88px",
535
												height: "88px",
536
												objectFit: "cover",
537
												border: "1px solid #d0d0d0",
538
											}}
539
										/>
540
									) : (
541
										<img
542
											src="/clouds.png"
543
											alt="Default"
544
											style={{
545
												width: "88px",
546
												height: "88px",
547
												objectFit: "cover",
548
												border: "1px solid #d0d0d0",
549
											}}
550
										/>
551
									)}
552
								</div>
553
554
								{/* Content on the right */}
555
								<div style={{ flex: 1, minWidth: 0 }}>
556
									{/* Title */}
557
									<h3
558
										style={{
559
											margin: "0 0 8px 0",
560
											fontSize: "15px",
561
											fontWeight: "normal",
562
											color: "#333",
563
											lineHeight: "1.3",
564
										}}
565
									>
566
										{doc.viewUrl ? (
567
											<a
568
												href={doc.viewUrl}
569
												target="_blank"
570
												rel="noopener noreferrer"
571
												style={{
572
													color: "#333",
573
													textDecoration: "none",
574
												}}
575
											>
576
												{doc.title}
577
											</a>
578
										) : (
579
											doc.title
580
										)}
581
									</h3>
582
583
									{/* Description */}
584
									{getDescription(doc) && (
585
										<p
586
											style={{
587
												margin: "0 0 8px 0",
588
												fontSize: "12px",
589
												color: "#666",
590
												lineHeight: "1.4",
591
												overflowWrap: "anywhere",
592
												wordBreak: "break-word",
593
											}}
594
										>
595
											{truncateText(getDescription(doc), 150)}
596
										</p>
597
									)}
598
599
									{/* Publication name and timestamp */}
600
									<div
601
										style={{
602
											display: "flex",
603
											alignItems: "center",
604
											justifyContent: "space-between",
605
											fontSize: "12px",
606
										}}
607
									>
608
										<a
609
											href={doc.publication?.url}
610
											target="_blank"
611
											rel="noreferrer"
612
											style={{
613
												color: "#7aaa3c",
614
												fontWeight: "bold",
615
											}}
616
										>
617
											{doc.publication?.name || "Unknown"}
618
										</a>
619
										<a
620
											href={`https://pdsls.dev/${doc.uri}`}
621
											target="_blank"
622
											rel="noreferrer"
623
											style={{
624
												color: "#999",
625
											}}
626
										>
627
											{formatDate(doc.publishedAt)}
628
										</a>
629
									</div>
630
								</div>
631
632
								{/* RSS icon on the far right */}
633
								{/*<div style={{ flexShrink: 0 }}>
634
									<svg
635
										width="24"
636
										height="24"
637
										viewBox="0 0 24 24"
638
										fill="none"
639
										xmlns="http://www.w3.org/2000/svg"
640
										style={{ opacity: 0.6 }}
641
									>
642
										<circle cx="6" cy="18" r="2" fill="#ff6600" />
643
										<path
644
											d="M4 4c9.941 0 18 8.059 18 18"
645
											stroke="#ff6600"
646
											strokeWidth="2"
647
											fill="none"
648
										/>
649
										<path
650
											d="M4 11c6.075 0 11 4.925 11 11"
651
											stroke="#ff6600"
652
											strokeWidth="2"
653
											fill="none"
654
										/>
655
									</svg>
656
								</div>*/}
657
							</div>
658
						))}
659
						{documents.length === 0 && <p>No documents found.</p>}
660
						</>
661
					)}
662
				</div>
663
			</div>
664
			<div className="status-bar">
665
				<p className="status-bar-field">Done</p>
666
			</div>
667
		</div>
668
	);
669
}
670
671
export default App;