this repo has no description
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}