From 2b00801a889da30a21873d147f1606cf4ff3acfe Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:40:37 +0000 Subject: [PATCH 1/9] Add document bookmarks --- app/bookmarks/route.ts | 173 +++++++++++++++++++++++++++++++++ app/d/[slug]/CommentsShell.tsx | 31 +++++- app/d/[slug]/page.tsx | 9 +- app/docs/route.ts | 4 +- app/route.ts | 1 + lib/docs/store.ts | 41 ++++++++ migrations/0015_bookmarks.sql | 9 ++ 7 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 app/bookmarks/route.ts create mode 100644 migrations/0015_bookmarks.sql diff --git a/app/bookmarks/route.ts b/app/bookmarks/route.ts new file mode 100644 index 0000000..2f1ff56 --- /dev/null +++ b/app/bookmarks/route.ts @@ -0,0 +1,173 @@ +import { getSession } from "@/lib/auth/session"; +import type { Session } from "@/lib/auth/session"; +import { sanitizeNext } from "@/lib/auth/url"; +import { canViewSession } from "@/lib/docs/access"; +import { accessRoleLabel, resolveAccess } from "@/lib/docs/grants"; +import { htmlResponse, manPage, esc, redirect } from "@/lib/page"; +import { query } from "@/lib/db"; +import { listBookmarks, saveBookmark, type BookmarkDocRow, findBySlug } from "@/lib/docs/store"; + +export const dynamic = "force-dynamic"; + +const LIST_LIMIT = 500; + +const ROW_STYLE = ` +`; + +function fmtDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toISOString().slice(0, 10); +} + +function row(opts: { + slug: string; + title: string | null; + access: string; + isPublic: boolean; + bookmarkedAt: string; + linkable: boolean; +}): string { + const label = opts.title && opts.title.trim() ? opts.title : opts.slug; + const href = `/d/${encodeURIComponent(opts.slug)}`; + const vis = opts.isPublic ? "public" : "private"; + const title = opts.linkable + ? `${esc(label)}` + : `${esc(label)}`; + return `
${title}  ${esc(opts.access)} ${vis} · ${esc(fmtDate(opts.bookmarkedAt))}
`; +} + +async function accountOwnerId(email: string): Promise { + const { rows } = await query<{ id: number }>(`SELECT id FROM users WHERE email = $1`, [email]); + return rows[0]?.id ?? null; +} + +async function bookmarkAccess( + doc: BookmarkDocRow, + session: Session, + ownerId: number | null +): Promise<{ access: string; linkable: boolean }> { + if (doc.deleted_at) return { access: "revoked", linkable: false }; + + if (ownerId != null && doc.owner_id === ownerId) { + return { access: "owner", linkable: true }; + } + + if (!(await canViewSession(doc, session, null))) { + return { access: "revoked", linkable: false }; + } + + const resolved = await resolveAccess(doc, session.email, ownerId ?? -1); + if (resolved.kind === "owner") return { access: "owner", linkable: true }; + if (resolved.kind === "none") return { access: "public", linkable: true }; + return { access: accessRoleLabel(resolved), linkable: true }; +} + +function emptySection(heading: string, copy: string): string { + return `

${heading}

+
${copy}
`; +} + +export async function GET(req: Request): Promise { + const session = await getSession(req); + if (!session) return redirect("/login?next=%2Fbookmarks"); + + const email = session.email; + const ownerId = session.user_id ?? (await accountOwnerId(email)); + const bookmarks = await listBookmarks(email, LIST_LIMIT); + + const owned: BookmarkDocRow[] = []; + const shared: BookmarkDocRow[] = []; + for (const bookmark of bookmarks) { + if (ownerId != null && bookmark.owner_id === ownerId) owned.push(bookmark); + else shared.push(bookmark); + } + + const intro = `
Signed in as ${esc(email)}.\n\nBookmarks track current access. If a bookmarked doc is later revoked, it stays\nlisted here with a revoked label and no link.
`; + + const ownedRows: string[] = []; + for (const doc of owned) { + const access = doc.deleted_at ? "revoked" : "owner"; + ownedRows.push( + row({ + slug: doc.slug, + title: doc.title, + access, + isPublic: doc.is_public, + bookmarkedAt: doc.bookmarked_at, + linkable: access !== "revoked", + }) + ); + } + + const sharedRows: string[] = []; + for (const doc of shared) { + const access = await bookmarkAccess(doc, session, ownerId); + sharedRows.push( + row({ + slug: doc.slug, + title: doc.title, + access: access.access, + isPublic: doc.is_public, + bookmarkedAt: doc.bookmarked_at, + linkable: access.linkable, + }) + ); + } + + const body = [ + ROW_STYLE, + intro, + owned.length + ? `

YOUR DOCUMENTS

+
+${ownedRows.join("\n")} +
` + : emptySection( + "YOUR DOCUMENTS", + "You haven't bookmarked any of your own documents yet. Use the bookmark\nbutton on a doc to save it here." + ), + shared.length + ? `

SHARED WITH YOU

+
+${sharedRows.join("\n")} +
` + : emptySection( + "SHARED WITH YOU", + "Nothing shared with you is bookmarked yet. Use the bookmark button on a\ndoc to save it here." + ), + ].join("\n"); + + return htmlResponse( + manPage({ + title: "justhtml.sh — bookmarks", + bodyHtml: body, + }) + ); +} + +export async function POST(req: Request): Promise { + const session = await getSession(req); + if (!session) return redirect("/login?next=%2Fbookmarks"); + + const form = await req.formData(); + const slug = String(form.get("slug") ?? "").trim(); + const viewtoken = String(form.get("viewtoken") ?? "").trim() || null; + const next = sanitizeNext(String(form.get("next") ?? (slug ? `/d/${encodeURIComponent(slug)}` : "/bookmarks"))); + + if (!slug) return redirect("/bookmarks"); + + const doc = await findBySlug(slug); + if (!doc || !(await canViewSession(doc, session, viewtoken))) { + return redirect(next); + } + + await saveBookmark(session.email, doc.id); + return redirect(next); +} diff --git a/app/d/[slug]/CommentsShell.tsx b/app/d/[slug]/CommentsShell.tsx index 0c058bc..c9d364d 100644 --- a/app/d/[slug]/CommentsShell.tsx +++ b/app/d/[slug]/CommentsShell.tsx @@ -79,6 +79,8 @@ type Props = { canComment: boolean; canReact: boolean; signedIn: boolean; + bookmarked: boolean; + bookmarkNext: string; me: string | null; initialThreads: Thread[]; initialDocReactions: Reaction[]; @@ -134,7 +136,19 @@ function legacyCopy(text: string): boolean { } export default function CommentsShell(props: Props) { - const { slug, title, rawSrc, viewtoken, canComment, canReact, signedIn, me, initialSections } = props; + const { + slug, + title, + rawSrc, + viewtoken, + canComment, + canReact, + signedIn, + bookmarked, + bookmarkNext, + me, + initialSections, + } = props; const iframeRef = useRef(null); const stageRef = useRef(null); const [threads, setThreads] = useState(props.initialThreads); @@ -782,6 +796,21 @@ export default function CommentsShell(props: Props) { + {signedIn ? ( +
+ + + {viewtoken ? : null} + +
+ ) : null}