diff --git a/src/lib/diff-render.ts b/src/lib/diff-render.ts index 4857b8e542d8065420bebc749c50808054a50e7f..6a1855f064b1f33db8a2b7317335c03013efb234 100644 GIT binary patch delta 1501 zcmX@3(yYHhlc!!^AtXOLH7~O&RUtF4BwwLCzbK_xN1;3;vm~{+ATc>rp{O*kSVti- zFGV3UFD0`qGo>^!N1>oJFS(>Nu_QA;Pa!!Yv8Y&&OJ83hG%qtbKP6Q+u{^OTRiQXv zAum5q*C(+gGf$y7xhS)sq*x&(Gc8RaIVUwSFQ-yZ!L>dyIYXf|FSD>TRiOmr5QWTQ zkV(0T1qG=o3MKgpi3-J;dFeT+3I#=(Wr-!Jx~0Xb3Lskw@-y>FbQDr@3)CUT#^)wx z=7F87ke{bel938BC^ZkHHbtShq$o2l9qN(tRE3mOkhns9Qetv8$eNPWiV~2v{L+$w z(h@y|kc`Y?g@U5|l+xtXVueJIQJ|pF%}Fgw%>nzuN})U>KPOceB&?8RY z`_#r1RQuJ&$7S$>8F=kb8)4C!Wk6E;K0eOO4TT~g8D@h#)ytpsOI7NirnJI4hMc@RaV5^XqTCU)mSYQJQY8?fLsdgHg)?85KkjPN5 zRY37YJw$_otsTf8P^ATlMJ2@wwva>t_BY5F&YaW|h5XVI1zQCrC2KBD&b0g@1r3N> z3P6Dl)}{$I2dp+TMZs19;Yz*q)Dn#Xut}VmX$l%3Y1?{Rh0?r~)U?dJ)D);zPLKq| z?qa>1)V%bP43KS{oRHwqD=00_fSJRIWPCBmcpZhz6o?hIT%4So`uYm~X=%l&B??KE z3WlIuQIe6W00|0kx~$L4OIOHCElVv@NY2kINd%=M25V5l)&u1haI%IPlv%6*a;8Fg zW=V!ZVo_3NNl{`^B{(~Pa#2aKLVig`YEgM+ajG6j3n-|xZ54vSiC-_RC_mRZBeBRi zKP469b!`PhkUQ%+LGFa6pZwAiYc9@Oc$6h7*h1Z^k(8R2Uz7^c0SWLVgj8Z$Noo;7 z2Ie_iR8MFm>L?`XD5NFk6sKw`Bo-?`5}rG9%Cm;2WT+u}xrqfD8qxU$I$&=W$7;e7 z3Megrvn@0+OipAMtq0|5c$6t*YHLGd6yjuXv_h1CN-a=8IF@K+YAWa|7=j}hqO2r8 z8F@#cxbpO^u~DJoF_ diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 35a72ca..aca8914 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -66,6 +66,7 @@ export const en: Messages = { historyEmpty: "No revisions yet.", historyDefaultMessage: "edit", backToNotePrefix: "Back to", + historyLoadMore: "View older revisions", }, editor: { title: "Title", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 9ca783f..af45ca3 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -67,6 +67,7 @@ export const fr: PartialMessages = { historyEmpty: "Aucune révision pour le moment.", historyDefaultMessage: "modification", backToNotePrefix: "Retour à", + historyLoadMore: "Voir les anciennes révisions", }, editor: { title: "Titre", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 57af38e..fc46755 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -68,6 +68,7 @@ export interface Messages { historyEmpty: string; historyDefaultMessage: string; backToNotePrefix: string; + historyLoadMore: string; }; editor: { title: string; diff --git a/src/server/db/queries/revision.ts b/src/server/db/queries/revision.ts index 14e75fa..a1de1a8 100644 --- a/src/server/db/queries/revision.ts +++ b/src/server/db/queries/revision.ts @@ -104,14 +104,25 @@ export interface RevisionHistoryRow { created_at: string; } -export function listRevisions(noteAtUri: string): RevisionHistoryRow[] { +export function listRevisions( + noteAtUri: string, + limit?: number, + beforeId?: number, +): RevisionHistoryRow[] { const db = getDb(); - return db - .query( - `SELECT id, did, at_uri, parent_revision_uri, diff, diff_format, message, created_at - FROM revisions WHERE note_at_uri = ? ORDER BY id DESC`, - ) - .all(noteAtUri) as RevisionHistoryRow[]; + const params: (string | number)[] = [noteAtUri]; + let sql = `SELECT id, did, at_uri, parent_revision_uri, diff, diff_format, message, created_at + FROM revisions WHERE note_at_uri = ?`; + if (beforeId !== undefined) { + sql += " AND id < ?"; + params.push(beforeId); + } + sql += " ORDER BY id DESC"; + if (limit !== undefined) { + sql += " LIMIT ?"; + params.push(limit); + } + return db.query(sql).all(...params) as RevisionHistoryRow[]; } export function getSnapshots(noteAtUri: string): SnapshotRow[] { diff --git a/src/server/routes/note.ts b/src/server/routes/note.ts index 8f5ab88..903fcc2 100644 --- a/src/server/routes/note.ts +++ b/src/server/routes/note.ts @@ -20,7 +20,12 @@ import { htmlResponse } from "../../lib/response.ts"; import { noteUrl, redirect, wikiUrl } from "../../lib/urls.ts"; import { resolveBookmarkHtml } from "../../views/bookmark.ts"; import { editNotePage } from "../../views/edit-note.ts"; -import { type HistoryEntry, historyPage } from "../../views/history.ts"; +import { + HISTORY_PAGE_SIZE, + type HistoryEntry, + historyEntriesFragment, + historyPage, +} from "../../views/history.ts"; import { newNotePage } from "../../views/new-note.ts"; import { notePage } from "../../views/note.ts"; import { shareOnBlueskyButton } from "../../views/share.ts"; @@ -171,7 +176,7 @@ export const noteRoutes = new Elysia() ) .get( "/@:handle/:wikiSlug/:noteSlug/-/history", - async ({ params, request }) => { + async ({ params, request, query }) => { const { handle: urlHandle, wikiSlug, noteSlug } = hwnp(params); const ctx = await resolveWikiContext( request, @@ -184,10 +189,21 @@ export const noteRoutes = new Elysia() const data = getNoteWithCurrent(ctx.wiki.at_uri, noteSlug); if (!data) throw new NotFoundError("Note not found"); - // Revisions DESC for display. Snapshots index keyed by revision_at_uri - // — used to compute clean diffs at render time. Pair each revision with - // the snapshot of its parent for the "before" content. - const revisions = listRevisions(data.note.at_uri); + // Pagination: `before` is the revision id cursor (load older than this). + // Fetch one extra row to detect whether more exist past this page. + const beforeId = query["before"] ? Number(query["before"]) : undefined; + const cursor = + beforeId !== undefined && Number.isFinite(beforeId) + ? beforeId + : undefined; + const fetched = listRevisions( + data.note.at_uri, + HISTORY_PAGE_SIZE + 1, + cursor, + ); + const hasMore = fetched.length > HISTORY_PAGE_SIZE; + const revisions = hasMore ? fetched.slice(0, HISTORY_PAGE_SIZE) : fetched; + const dids = Array.from(new Set(revisions.map((r) => r.did))); const profiles = getCachedProfilesByDids(dids); const snapshots = getSnapshots(data.note.at_uri); @@ -202,6 +218,7 @@ export const noteRoutes = new Elysia() ? snapByUri.get(r.parent_revision_uri) : ""; return { + id: r.id, at_uri: r.at_uri, did: r.did, handle: p?.handle ?? null, @@ -215,6 +232,21 @@ export const noteRoutes = new Elysia() }; }); + // HTMX request → return just the next-batch fragment so the existing + // "View more" button can swap itself out for the new entries + button. + const isHxRequest = request.headers.get("HX-Request") === "true"; + if (isHxRequest) { + const historyUrl = `/@${handle}/${ctx.wiki.slug}/${noteSlug}/-/history`; + return htmlResponse( + historyEntriesFragment( + entries, + hasMore, + historyUrl, + ctx.locale ?? "en", + ), + ); + } + return htmlResponse( historyPage( ctx.wiki.name, @@ -223,6 +255,7 @@ export const noteRoutes = new Elysia() noteSlug, data.note.title, entries, + hasMore, { session: ctx.session, locale: ctx.locale, diff --git a/src/views/history.ts b/src/views/history.ts index 29b4484..f56fb4a 100644 --- a/src/views/history.ts +++ b/src/views/history.ts @@ -5,7 +5,10 @@ import { noteUrl, profileUrl } from "../lib/urls.ts"; import { type LayoutOptions, layout } from "./layout.ts"; import { THEME } from "./theme/index.ts"; +export const HISTORY_PAGE_SIZE = 25; + export interface HistoryEntry { + id: number; at_uri: string; did: string; handle: string | null; @@ -39,6 +42,60 @@ function authorLabel(entry: HistoryEntry): string { return `@${escapeHtml(handleOrDid)}`; } +function renderEntry(entry: HistoryEntry, locale: Locale): string { + const msg = t(locale); + const date = formatDate(entry.created_at, locale); + const message = entry.message?.trim() || msg.wiki.historyDefaultMessage; + const summary = ` + + ${escapeHtml(date)} + ${authorLabel(entry)}: ${escapeHtml(message)} + `; + const canSnapshotDiff = entry.afterContent !== null; + const diffBody = canSnapshotDiff + ? renderContentDiff(entry.beforeContent ?? "", entry.afterContent ?? "") + : entry.diff_format === "diff-match-patch" + ? renderDiffInline(entry.diff) + : `
${escapeHtml(entry.diff)}
`; + return `
+ ${summary} +
${diffBody}
+
`; +} + +function renderViewMore( + historyUrl: string, + beforeId: number, + locale: Locale, +): string { + const msg = t(locale); + const href = `${historyUrl}?before=${beforeId}`; + // HTMX swaps this button with the next batch (entries + maybe a new + // button). Plain anchor href acts as a no-JS fallback that reloads the + // page with the cursor; for HX requests we return only the fragment. + return `${msg.wiki.historyLoadMore}`; +} + +/** + * Renders the next-page fragment for HTMX swaps: just the entries plus an + * optional "View more" button. No layout wrapper. + */ +export function historyEntriesFragment( + entries: HistoryEntry[], + hasMore: boolean, + historyUrl: string, + locale: Locale, +): string { + const items = entries.map((e) => renderEntry(e, locale)).join("\n"); + const more = + hasMore && entries.length > 0 + ? renderViewMore(historyUrl, entries[entries.length - 1]?.id ?? 0, locale) + : ""; + return `${items}\n${more}`; +} + export function historyPage( wikiName: string, ownerHandle: string, @@ -46,6 +103,7 @@ export function historyPage( noteSlug: string, noteTitle: string, entries: HistoryEntry[], + hasMore: boolean, options: LayoutOptions, ): string { const locale = options.locale ?? "en"; @@ -56,34 +114,12 @@ export function historyPage( const heading = `

${msg.wiki.historyHeading}

`; + const historyUrl = `${noteUrl(ownerHandle, wikiSlug, noteSlug)}/-/history`; + const body = entries.length === 0 ? `

${msg.wiki.historyEmpty}

` - : entries - .map((entry) => { - const date = formatDate(entry.created_at, locale); - const message = - entry.message?.trim() || msg.wiki.historyDefaultMessage; - const summary = ` - - ${escapeHtml(date)} - ${authorLabel(entry)}: ${escapeHtml(message)} - `; - const canSnapshotDiff = entry.afterContent !== null; - const diffBody = canSnapshotDiff - ? renderContentDiff( - entry.beforeContent ?? "", - entry.afterContent ?? "", - ) - : entry.diff_format === "diff-match-patch" - ? renderDiffInline(entry.diff) - : `
${escapeHtml(entry.diff)}
`; - return `
- ${summary} -
${diffBody}
-
`; - }) - .join("\n"); + : historyEntriesFragment(entries, hasMore, historyUrl, locale); return layout( `${msg.wiki.historyHeading} — ${noteTitle}`,