A fullstack app for indexing standard.site documents
at main 663 lines 15 kB view raw
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;