this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 835 lines 22 kB view raw
1import { Hono } from "hono"; 2import { html, raw } from "hono/html"; 3import { layout } from "../views/layouts/main"; 4import { requireAuth, type Session } from "../lib/session"; 5import { csrfField } from "../lib/csrf"; 6import { isValidTID } from "../lib/validation"; 7import { 8 createMarkdownContent, 9 getDocumentContentText, 10} from "../lib/content-types"; 11import { marked } from "marked"; 12import type { AppVariables } from "../types"; 13 14export const documentRoutes = new Hono<{ Variables: AppVariables }>(); 15 16const DOCUMENT_COLLECTION = "site.standard.document"; 17const PUBLICATION_COLLECTION = "site.standard.publication"; 18 19// List all documents 20documentRoutes.get("/", async (c) => { 21 let session: Session; 22 try { 23 session = requireAuth(c); 24 } catch { 25 return c.redirect("/auth/login"); 26 } 27 28 const filter = c.req.query("filter") || "all"; 29 30 try { 31 const response = await session.agent!.com.atproto.repo.listRecords({ 32 repo: session.did!, 33 collection: DOCUMENT_COLLECTION, 34 limit: 100, 35 }); 36 37 let documents = response.data.records; 38 39 // Filter by draft/published status 40 if (filter === "drafts") { 41 documents = documents.filter((doc: any) => { 42 const tags = doc.value.tags || []; 43 return tags.includes("draft"); 44 }); 45 } else if (filter === "published") { 46 documents = documents.filter((doc: any) => { 47 const tags = doc.value.tags || []; 48 return !tags.includes("draft"); 49 }); 50 } 51 52 // Sort by publishedAt or updatedAt 53 documents.sort((a: any, b: any) => { 54 const dateA = new Date( 55 a.value.updatedAt || a.value.publishedAt, 56 ).getTime(); 57 const dateB = new Date( 58 b.value.updatedAt || b.value.publishedAt, 59 ).getTime(); 60 return dateB - dateA; 61 }); 62 63 const content = html` 64 <div class="documents"> 65 <div class="documents-header"> 66 <h1>Documents</h1> 67 <a href="/documents/new" class="btn btn-primary">New Document</a> 68 </div> 69 70 <div class="filters"> 71 <a 72 href="/documents" 73 class="filter ${filter === "all" ? "active" : ""}" 74 >All</a 75 > 76 <a 77 href="/documents?filter=drafts" 78 class="filter ${filter === "drafts" ? "active" : ""}" 79 >Drafts</a 80 > 81 <a 82 href="/documents?filter=published" 83 class="filter ${filter === "published" ? "active" : ""}" 84 >Published</a 85 > 86 </div> 87 88 ${ 89 documents.length === 0 90 ? html` 91 <p class="empty"> 92 No documents yet. 93 <a href="/documents/new">Create your first document</a>. 94 </p> 95 ` 96 : html` 97 <ul class="document-list"> 98 ${documents.map((doc: any) => { 99 const rkey = doc.uri.split("/").pop(); 100 const value = doc.value; 101 const isDraft = (value.tags || []).includes("draft"); 102 const date = value.publishedAt 103 ? new Date(value.publishedAt).toLocaleDateString() 104 : ""; 105 106 return html` 107 <li 108 class="document-item ${isDraft ? "draft" : "published"}" 109 > 110 <a href="/documents/${rkey}"> 111 <span class="title">${value.title}</span> 112 <span class="meta"> 113 ${ 114 isDraft 115 ? html`<span class="badge badge-draft">Draft</span>` 116 : "" 117 } 118 <span class="date">${date}</span> 119 </span> 120 </a> 121 </li> 122 `; 123 })} 124 </ul> 125 ` 126 } 127 </div> 128 `; 129 130 return c.html(layout(content, { title: "Documents - sitebase", session })); 131 } catch (error) { 132 console.error("Error fetching documents:", error); 133 const content = html`<p class="error"> 134 Error loading documents. Please try again. 135 </p>`; 136 return c.html(layout(content, { title: "Documents - sitebase", session })); 137 } 138}); 139 140// New document form 141documentRoutes.get("/new", async (c) => { 142 let session: Session; 143 try { 144 session = requireAuth(c); 145 } catch { 146 return c.redirect("/auth/login"); 147 } 148 149 // Get publication to use as site reference 150 let publicationUri = ""; 151 try { 152 const response = await session.agent!.com.atproto.repo.listRecords({ 153 repo: session.did!, 154 collection: PUBLICATION_COLLECTION, 155 limit: 1, 156 }); 157 if (response.data.records[0]) { 158 publicationUri = response.data.records[0].uri; 159 } 160 } catch (e) { 161 // No publication yet, will need URL 162 } 163 164 const csrfToken = c.get("csrfToken") as string; 165 166 const content = html` 167 <div class="form-page"> 168 <h1>New Document</h1> 169 170 <form action="/documents/new" method="POST" class="document-form"> 171 ${csrfField(csrfToken)} 172 <input type="hidden" name="publicationUri" value="${publicationUri}" /> 173 174 <div class="form-group"> 175 <label for="title">Title *</label> 176 <input type="text" id="title" name="title" required maxlength="128" /> 177 </div> 178 179 <div class="form-group"> 180 <label for="path">Path</label> 181 <input type="text" id="path" name="path" placeholder="/my-post" /> 182 <small>The URL path for this document (e.g., /my-post)</small> 183 </div> 184 185 <div class="form-group"> 186 <label for="description">Description</label> 187 <textarea 188 id="description" 189 name="description" 190 rows="2" 191 maxlength="300" 192 ></textarea> 193 </div> 194 195 <div class="form-group"> 196 <label for="content">Content (Markdown)</label> 197 <textarea 198 id="content" 199 name="content" 200 rows="20" 201 class="content-editor" 202 ></textarea> 203 </div> 204 205 <div class="form-group"> 206 <label for="publishDate">Publish Date</label> 207 <input type="datetime-local" id="publishDate" name="publishDate" /> 208 <small 209 >Only past dates allowed. Leave empty to use current date when 210 publishing.</small 211 > 212 </div> 213 214 <div class="form-group"> 215 <label for="tags">Tags (comma-separated)</label> 216 <input 217 type="text" 218 id="tags" 219 name="tags" 220 placeholder="draft, tutorial" 221 value="draft" 222 /> 223 </div> 224 225 <div class="form-actions"> 226 <button 227 type="submit" 228 name="action" 229 value="save" 230 class="btn btn-secondary" 231 > 232 Save Draft 233 </button> 234 <button 235 type="submit" 236 name="action" 237 value="publish" 238 class="btn btn-primary" 239 > 240 Publish 241 </button> 242 </div> 243 </form> 244 </div> 245 246 <script> 247 // Set default publish date to current date/time 248 const publishDateInput = document.getElementById("publishDate"); 249 if (publishDateInput) { 250 const now = new Date(); 251 now.setMinutes(now.getMinutes() - now.getTimezoneOffset()); 252 publishDateInput.value = now.toISOString().slice(0, 16); 253 } 254 255 // Auto-save functionality 256 const form = document.querySelector(".document-form"); 257 const contentField = document.getElementById("content"); 258 let saveTimeout; 259 260 contentField.addEventListener("input", () => { 261 clearTimeout(saveTimeout); 262 saveTimeout = setTimeout(() => { 263 // Could implement auto-save here 264 console.log("Would auto-save..."); 265 }, 2000); 266 }); 267 268 // Validate publish date is not in the future 269 form.addEventListener("submit", (e) => { 270 const publishDate = publishDateInput?.value; 271 if (publishDate) { 272 const selectedDate = new Date(publishDate); 273 const now = new Date(); 274 if (selectedDate > now) { 275 e.preventDefault(); 276 alert("Publish date must be in the past or present."); 277 publishDateInput.focus(); 278 } 279 } 280 }); 281 </script> 282 `; 283 284 return c.html(layout(content, { title: "New Document - sitebase", session })); 285}); 286 287// Handle document creation 288documentRoutes.post("/new", async (c) => { 289 let session: Session; 290 try { 291 session = requireAuth(c); 292 } catch { 293 return c.redirect("/auth/login"); 294 } 295 296 const body = await c.req.parseBody(); 297 const title = body.title as string; 298 const path = (body.path as string) || undefined; 299 const description = (body.description as string) || undefined; 300 const content = (body.content as string) || undefined; 301 const tagsStr = (body.tags as string) || ""; 302 const action = body.action as string; 303 const publicationUri = body.publicationUri as string; 304 const publishDateStr = body.publishDate as string; 305 306 // Parse tags 307 let tags = tagsStr 308 .split(",") 309 .map((t) => t.trim()) 310 .filter((t) => t); 311 312 // If publishing, remove draft tag 313 if (action === "publish") { 314 tags = tags.filter((t) => t !== "draft"); 315 } else if (!tags.includes("draft")) { 316 tags.push("draft"); 317 } 318 319 const now = new Date().toISOString(); 320 321 // Determine publish date 322 let publishedAt: string | undefined; 323 if (action === "publish") { 324 if (publishDateStr) { 325 const parsedDate = new Date(publishDateStr); 326 if (!isNaN(parsedDate.getTime())) { 327 publishedAt = parsedDate.toISOString(); 328 } 329 } 330 if (!publishedAt) { 331 publishedAt = now; 332 } 333 } 334 335 try { 336 const rkey = generateTID(); 337 338 // Determine site reference 339 let site = publicationUri; 340 if (!site) { 341 // Fall back to a URL if no publication 342 site = `https://${session.handle}.bsky.social`; 343 } 344 345 const record: Record<string, any> = { 346 $type: DOCUMENT_COLLECTION, 347 title, 348 site, 349 publishedAt, 350 updatedAt: now, 351 }; 352 353 if (path) record.path = path.startsWith("/") ? path : `/${path}`; 354 if (description) record.description = description; 355 if (content) { 356 record.content = createMarkdownContent(content); 357 record.textContent = content; 358 } 359 if (tags.length > 0) record.tags = tags; 360 361 await session.agent!.com.atproto.repo.createRecord({ 362 repo: session.did!, 363 collection: DOCUMENT_COLLECTION, 364 rkey, 365 record, 366 }); 367 368 return c.redirect(`/documents/${rkey}`); 369 } catch (error) { 370 console.error("Error creating document:", error); 371 return c.redirect("/documents/new?error=create_failed"); 372 } 373}); 374 375// View single document 376documentRoutes.get("/:rkey", async (c) => { 377 let session: Session; 378 try { 379 session = requireAuth(c); 380 } catch { 381 return c.redirect("/auth/login"); 382 } 383 384 const rkey = c.req.param("rkey"); 385 386 // Validate rkey format 387 if (!isValidTID(rkey)) { 388 return c.redirect("/documents"); 389 } 390 391 try { 392 const response = await session.agent!.com.atproto.repo.getRecord({ 393 repo: session.did!, 394 collection: DOCUMENT_COLLECTION, 395 rkey, 396 }); 397 398 const doc = response.data.value as any; 399 const isDraft = (doc.tags || []).includes("draft"); 400 const csrfToken = c.get("csrfToken") as string; 401 402 const content = html` 403 <div class="document-view"> 404 <div class="document-header"> 405 <h1>${doc.title}</h1> 406 <div class="document-meta"> 407 ${ 408 isDraft 409 ? html`<span class="badge badge-draft">Draft</span>` 410 : html`<span class="badge badge-published">Published</span>` 411 } 412 ${ 413 doc.publishedAt 414 ? html`<span class="date" 415 >Published: 416 ${new Date(doc.publishedAt).toLocaleDateString()}</span 417 >` 418 : "" 419 } 420 ${doc.path ? html`<span class="path">Path: ${doc.path}</span>` : ""} 421 </div> 422 </div> 423 424 ${ 425 doc.description 426 ? html`<p class="description">${doc.description}</p>` 427 : "" 428 } 429 430 <div class="document-content"> 431 ${(() => { 432 const text = getDocumentContentText(doc); 433 if (!text) return html`<p class="empty">(No content)</p>`; 434 const htmlContent = marked.parse(text) as string; 435 return html`<div class="markdown-body">${raw(htmlContent)}</div>`; 436 })()} 437 </div> 438 439 <div class="actions"> 440 <a href="/documents/${rkey}/edit" class="btn btn-primary">Edit</a> 441 ${ 442 isDraft 443 ? html` 444 <form 445 action="/documents/${rkey}/publish" 446 method="POST" 447 style="display:inline" 448 > 449 ${csrfField(csrfToken)} 450 <button type="submit" class="btn btn-success">Publish</button> 451 </form> 452 ` 453 : html` 454 <form 455 action="/documents/${rkey}/unpublish" 456 method="POST" 457 style="display:inline" 458 > 459 ${csrfField(csrfToken)} 460 <button type="submit" class="btn btn-secondary"> 461 Unpublish 462 </button> 463 </form> 464 ` 465 } 466 <form 467 action="/documents/${rkey}/delete" 468 method="POST" 469 style="display:inline" 470 onsubmit="return confirm('Are you sure you want to delete this document?')" 471 > 472 ${csrfField(csrfToken)} 473 <button type="submit" class="btn btn-danger">Delete</button> 474 </form> 475 <a href="/documents" class="btn btn-secondary">Back to List</a> 476 </div> 477 </div> 478 `; 479 480 return c.html( 481 layout(content, { title: `${doc.title} - sitebase`, session }), 482 ); 483 } catch (error) { 484 console.error("Error fetching document:", error); 485 return c.redirect("/documents"); 486 } 487}); 488 489// Edit document form 490documentRoutes.get("/:rkey/edit", async (c) => { 491 let session: Session; 492 try { 493 session = requireAuth(c); 494 } catch { 495 return c.redirect("/auth/login"); 496 } 497 498 const rkey = c.req.param("rkey"); 499 500 if (!isValidTID(rkey)) { 501 return c.redirect("/documents"); 502 } 503 504 try { 505 const response = await session.agent!.com.atproto.repo.getRecord({ 506 repo: session.did!, 507 collection: DOCUMENT_COLLECTION, 508 rkey, 509 }); 510 511 const doc = response.data.value as any; 512 const csrfToken = c.get("csrfToken") as string; 513 514 const content = html` 515 <div class="form-page"> 516 <h1>Edit Document</h1> 517 518 <form 519 action="/documents/${rkey}/edit" 520 method="POST" 521 class="document-form" 522 > 523 ${csrfField(csrfToken)} 524 <div class="form-group"> 525 <label for="title">Title *</label> 526 <input 527 type="text" 528 id="title" 529 name="title" 530 value="${doc.title}" 531 required 532 maxlength="128" 533 /> 534 </div> 535 536 <div class="form-group"> 537 <label for="path">Path</label> 538 <input 539 type="text" 540 id="path" 541 name="path" 542 value="${doc.path || ""}" 543 /> 544 </div> 545 546 <div class="form-group"> 547 <label for="description">Description</label> 548 <textarea 549 id="description" 550 name="description" 551 rows="2" 552 maxlength="300" 553 > 554${doc.description || ""}</textarea 555 > 556 </div> 557 558 <div class="form-group"> 559 <label for="content">Content (Markdown)</label> 560 <textarea 561 id="content" 562 name="content" 563 rows="20" 564 class="content-editor" 565 > 566${getDocumentContentText(doc) || ""}</textarea 567 > 568 </div> 569 570 <div class="form-group"> 571 <label for="tags">Tags (comma-separated)</label> 572 <input 573 type="text" 574 id="tags" 575 name="tags" 576 value="${(doc.tags || []).join(", ")}" 577 /> 578 </div> 579 580 <div class="form-group"> 581 <label for="publishDate">Publish Date</label> 582 <input 583 type="datetime-local" 584 id="publishDate" 585 name="publishDate" 586 value="${ 587 doc.publishedAt 588 ? new Date(doc.publishedAt).toISOString().slice(0, 16) 589 : "" 590 }" 591 /> 592 <small 593 >Only past dates allowed. Set to change the published date.</small 594 > 595 </div> 596 597 <div class="form-actions"> 598 <button type="submit" class="btn btn-primary">Save Changes</button> 599 <a href="/documents/${rkey}" class="btn btn-secondary">Cancel</a> 600 </div> 601 </form> 602 </div> 603 604 <script> 605 const form = document.querySelector(".document-form"); 606 const publishDateInput = document.getElementById("publishDate"); 607 608 form.addEventListener("submit", (e) => { 609 const publishDate = publishDateInput?.value; 610 if (publishDate) { 611 const selectedDate = new Date(publishDate); 612 const now = new Date(); 613 if (selectedDate > now) { 614 e.preventDefault(); 615 alert("Publish date must be in the past or present."); 616 publishDateInput.focus(); 617 } 618 } 619 }); 620 </script> 621 `; 622 623 return c.html( 624 layout(content, { title: `Edit: ${doc.title} - sitebase`, session }), 625 ); 626 } catch (error) { 627 console.error("Error fetching document:", error); 628 return c.redirect("/documents"); 629 } 630}); 631 632// Handle document update 633documentRoutes.post("/:rkey/edit", async (c) => { 634 let session: Session; 635 try { 636 session = requireAuth(c); 637 } catch { 638 return c.redirect("/auth/login"); 639 } 640 641 const rkey = c.req.param("rkey"); 642 643 if (!isValidTID(rkey)) { 644 return c.redirect("/documents"); 645 } 646 647 const body = await c.req.parseBody(); 648 649 try { 650 // Get existing record 651 const existing = await session.agent!.com.atproto.repo.getRecord({ 652 repo: session.did!, 653 collection: DOCUMENT_COLLECTION, 654 rkey, 655 }); 656 657 const oldDoc = existing.data.value as any; 658 659 const title = body.title as string; 660 const path = (body.path as string) || undefined; 661 const description = (body.description as string) || undefined; 662 const content = (body.content as string) || undefined; 663 const tagsStr = (body.tags as string) || ""; 664 const publishDateStr = body.publishDate as string; 665 const tags = tagsStr 666 .split(",") 667 .map((t) => t.trim()) 668 .filter((t) => t); 669 670 // Determine publishedAt 671 let publishedAt = oldDoc.publishedAt; 672 if (publishDateStr) { 673 const parsedDate = new Date(publishDateStr); 674 if (!isNaN(parsedDate.getTime())) { 675 publishedAt = parsedDate.toISOString(); 676 } 677 } 678 679 const record: Record<string, any> = { 680 $type: DOCUMENT_COLLECTION, 681 title, 682 site: oldDoc.site, 683 publishedAt, 684 updatedAt: new Date().toISOString(), 685 }; 686 687 if (path) record.path = path.startsWith("/") ? path : `/${path}`; 688 if (description) record.description = description; 689 if (content) { 690 record.content = createMarkdownContent(content); 691 record.textContent = content; 692 } 693 if (tags.length > 0) record.tags = tags; 694 695 await session.agent!.com.atproto.repo.putRecord({ 696 repo: session.did!, 697 collection: DOCUMENT_COLLECTION, 698 rkey, 699 record, 700 }); 701 702 return c.redirect(`/documents/${rkey}`); 703 } catch (error) { 704 console.error("Error updating document:", error); 705 return c.redirect(`/documents/${rkey}/edit?error=update_failed`); 706 } 707}); 708 709// Publish document 710documentRoutes.post("/:rkey/publish", async (c) => { 711 let session: Session; 712 try { 713 session = requireAuth(c); 714 } catch { 715 return c.redirect("/auth/login"); 716 } 717 718 const rkey = c.req.param("rkey"); 719 720 if (!isValidTID(rkey)) { 721 return c.redirect("/documents"); 722 } 723 724 try { 725 const existing = await session.agent!.com.atproto.repo.getRecord({ 726 repo: session.did!, 727 collection: DOCUMENT_COLLECTION, 728 rkey, 729 }); 730 731 const doc = existing.data.value as any; 732 const tags = (doc.tags || []).filter((t: string) => t !== "draft"); 733 734 const record = { 735 ...doc, 736 tags: tags.length > 0 ? tags : undefined, 737 publishedAt: doc.publishedAt || new Date().toISOString(), 738 updatedAt: new Date().toISOString(), 739 }; 740 741 await session.agent!.com.atproto.repo.putRecord({ 742 repo: session.did!, 743 collection: DOCUMENT_COLLECTION, 744 rkey, 745 record, 746 }); 747 748 return c.redirect(`/documents/${rkey}`); 749 } catch (error) { 750 console.error("Error publishing document:", error); 751 return c.redirect(`/documents/${rkey}?error=publish_failed`); 752 } 753}); 754 755// Unpublish document (add draft tag) 756documentRoutes.post("/:rkey/unpublish", async (c) => { 757 let session: Session; 758 try { 759 session = requireAuth(c); 760 } catch { 761 return c.redirect("/auth/login"); 762 } 763 764 const rkey = c.req.param("rkey"); 765 766 if (!isValidTID(rkey)) { 767 return c.redirect("/documents"); 768 } 769 770 try { 771 const existing = await session.agent!.com.atproto.repo.getRecord({ 772 repo: session.did!, 773 collection: DOCUMENT_COLLECTION, 774 rkey, 775 }); 776 777 const doc = existing.data.value as any; 778 const tags = [...(doc.tags || []), "draft"]; 779 780 const record = { 781 ...doc, 782 tags, 783 updatedAt: new Date().toISOString(), 784 }; 785 786 await session.agent!.com.atproto.repo.putRecord({ 787 repo: session.did!, 788 collection: DOCUMENT_COLLECTION, 789 rkey, 790 record, 791 }); 792 793 return c.redirect(`/documents/${rkey}`); 794 } catch (error) { 795 console.error("Error unpublishing document:", error); 796 return c.redirect(`/documents/${rkey}?error=unpublish_failed`); 797 } 798}); 799 800// Delete document 801documentRoutes.post("/:rkey/delete", async (c) => { 802 let session: Session; 803 try { 804 session = requireAuth(c); 805 } catch { 806 return c.redirect("/auth/login"); 807 } 808 809 const rkey = c.req.param("rkey"); 810 811 if (!isValidTID(rkey)) { 812 return c.redirect("/documents"); 813 } 814 815 try { 816 await session.agent!.com.atproto.repo.deleteRecord({ 817 repo: session.did!, 818 collection: DOCUMENT_COLLECTION, 819 rkey, 820 }); 821 822 return c.redirect("/documents"); 823 } catch (error) { 824 console.error("Error deleting document:", error); 825 return c.redirect(`/documents/${rkey}?error=delete_failed`); 826 } 827}); 828 829// Generate a TID (timestamp-based ID) 830function generateTID(): string { 831 const now = Date.now() * 1000; 832 const clockId = Math.floor(Math.random() * 1024); 833 const tid = (BigInt(now) << 10n) | BigInt(clockId); 834 return tid.toString(36).padStart(13, "0"); 835}