From b29dc907e85299c702e2c78b34482a6f3f2bb14d Mon Sep 17 00:00:00 2001 From: Soushi888 Date: Tue, 17 Mar 2026 23:10:46 -0400 Subject: [PATCH] feat(components): add RecentChanges component Adds a RecentChanges component that displays a live activity feed of recently created and modified notes, with richer UX than RecentNotes. Features: - Created vs. modified distinction (badge + 1h threshold heuristic) - Tab filter UI: All / New (by creation date) / Updated (modified only) - Load-more pagination per tab with configurable page size - Client-side relative timestamps via Intl.RelativeTimeFormat (locale-aware) - Progressive enhancement: SSR initial render + JSON data island for client - localStorage persistence of the active filter tab - Fully i18n: all UI strings go through cfg.locale New files: - quartz/components/RecentChanges.tsx - quartz/components/scripts/recentChanges.inline.ts - quartz/components/utils/recentChanges.ts - quartz/components/styles/recentChanges.scss Modified: - quartz/components/index.ts: export RecentChanges - quartz/i18n/locales/definition.ts: add recentChanges translation block - quartz/i18n/locales/*.ts (30 files): add English fallback translations --- package-lock.json | 40 +-- quartz/components/RecentChanges.tsx | 269 ++++++++++++++++++ quartz/components/index.ts | 2 + .../scripts/recentChanges.inline.ts | 238 ++++++++++++++++ quartz/components/styles/recentChanges.scss | 214 ++++++++++++++ quartz/components/utils/recentChanges.ts | 12 + quartz/i18n/locales/ar-SA.ts | 10 + quartz/i18n/locales/ca-ES.ts | 10 + quartz/i18n/locales/cs-CZ.ts | 10 + quartz/i18n/locales/de-DE.ts | 10 + quartz/i18n/locales/definition.ts | 10 + quartz/i18n/locales/en-GB.ts | 10 + quartz/i18n/locales/en-US.ts | 10 + quartz/i18n/locales/es-ES.ts | 10 + quartz/i18n/locales/fa-IR.ts | 10 + quartz/i18n/locales/fi-FI.ts | 10 + quartz/i18n/locales/fr-FR.ts | 10 + quartz/i18n/locales/he-IL.ts | 10 + quartz/i18n/locales/hu-HU.ts | 10 + quartz/i18n/locales/id-ID.ts | 10 + quartz/i18n/locales/it-IT.ts | 10 + quartz/i18n/locales/ja-JP.ts | 10 + quartz/i18n/locales/kk-KZ.ts | 10 + quartz/i18n/locales/ko-KR.ts | 10 + quartz/i18n/locales/lt-LT.ts | 10 + quartz/i18n/locales/nb-NO.ts | 10 + quartz/i18n/locales/nl-NL.ts | 10 + quartz/i18n/locales/pl-PL.ts | 10 + quartz/i18n/locales/pt-BR.ts | 10 + quartz/i18n/locales/ro-RO.ts | 10 + quartz/i18n/locales/ru-RU.ts | 10 + quartz/i18n/locales/th-TH.ts | 10 + quartz/i18n/locales/tr-TR.ts | 10 + quartz/i18n/locales/uk-UA.ts | 10 + quartz/i18n/locales/vi-VN.ts | 10 + quartz/i18n/locales/zh-CN.ts | 10 + quartz/i18n/locales/zh-TW.ts | 10 + 37 files changed, 1054 insertions(+), 31 deletions(-) create mode 100644 quartz/components/RecentChanges.tsx create mode 100644 quartz/components/scripts/recentChanges.inline.ts create mode 100644 quartz/components/styles/recentChanges.scss create mode 100644 quartz/components/utils/recentChanges.ts diff --git a/package-lock.json b/package-lock.json index ff22d60fc..229dc5fde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -97,13 +97,13 @@ "version": "2.10.2", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", - "license": "(Apache-2.0 AND BSD-3-Clause)", - "peer": true + "license": "(Apache-2.0 AND BSD-3-Clause)" }, "node_modules/@citation-js/core": { "version": "0.7.14", "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.14.tgz", "integrity": "sha512-dgeGqYDSQmn2MtnWZkwPGpJQPh43yr1lAAr9jl1NJ9pIY1RXUQxtlAUZVur0V9PHdbfQC+kkvB1KC3VpgVV3MA==", + "peer": true, "dependencies": { "@citation-js/date": "^0.5.0", "@citation-js/name": "^0.4.2", @@ -2611,8 +2611,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", - "license": "MIT/X11", - "peer": true + "license": "MIT/X11" }, "node_modules/buffer-from": { "version": "1.1.2", @@ -2732,8 +2731,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/comma-separated-tokens": { "version": "2.0.3", @@ -3106,6 +3104,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "peer": true, "engines": { "node": ">=12" } @@ -3284,6 +3283,7 @@ "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3696,7 +3696,6 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -5854,6 +5853,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -6415,7 +6415,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -6500,7 +6499,6 @@ ], "license": "MIT", "optional": true, - "peer": true, "dependencies": { "sass": "1.97.2" } @@ -6517,7 +6515,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6534,7 +6531,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6551,7 +6547,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6568,7 +6563,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6585,7 +6579,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6602,7 +6595,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6619,7 +6611,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6636,7 +6627,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6653,7 +6643,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6670,7 +6659,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6687,7 +6675,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6704,7 +6691,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6721,7 +6707,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6738,7 +6723,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6755,7 +6739,6 @@ "!linux", "!win32" ], - "peer": true, "dependencies": { "sass": "1.97.2" } @@ -6772,7 +6755,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6789,7 +6771,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=14.0.0" } @@ -6959,6 +6940,7 @@ "version": "1.26.2", "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.26.2.tgz", "integrity": "sha512-iP7u2NA9A6JwRRCkIUREEX2cMhlYV5EBmbbSlfSRvPThwca8HBRbVkWuNWW+kw9+i6BSUZqqG6YeUs5dC2SjZw==", + "peer": true, "dependencies": { "@shikijs/core": "1.26.2", "@shikijs/engine-javascript": "1.26.2", @@ -7130,7 +7112,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7158,7 +7139,6 @@ "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "license": "MIT", - "peer": true, "dependencies": { "sync-message-port": "^1.0.0" }, @@ -7183,7 +7163,6 @@ "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz", "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.0.0" } @@ -7492,8 +7471,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vfile": { "version": "6.0.3", diff --git a/quartz/components/RecentChanges.tsx b/quartz/components/RecentChanges.tsx new file mode 100644 index 000000000..871155e03 --- /dev/null +++ b/quartz/components/RecentChanges.tsx @@ -0,0 +1,269 @@ +import { QuartzComponent, QuartzComponentConstructor, QuartzComponentProps } from "./types" +import { FullSlug, SimpleSlug, resolveRelative } from "../util/path" +import { QuartzPluginData } from "../plugins/vfile" +import { classNames } from "../util/lang" +import { i18n } from "../i18n" +import { GlobalConfiguration } from "../cfg" +import style from "./styles/recentChanges.scss" +import { ChangedItem } from "./utils/recentChanges" +// @ts-ignore +import script from "./scripts/recentChanges.inline" + +interface Options { + title?: string + limit: number + showCreated: boolean + showModified: boolean + detailed: boolean + filterBy: string[] + showExcerpt: boolean + showTags: boolean + showFilter: boolean + pageSize: number + linkToMore: SimpleSlug | false + pages: FullSlug[] +} + +const defaultOptions = (_cfg: GlobalConfiguration): Options => ({ + limit: 10, + showCreated: true, + showModified: true, + detailed: false, + filterBy: [], + showExcerpt: false, + showTags: false, + showFilter: false, + pageSize: 20, + linkToMore: false, + pages: [], +}) + +function formatRelativeDate(date: Date, locale: string): string { + const diffMs = Date.now() - date.getTime() + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }) + const abs = (n: number) => Math.abs(n) + const s = Math.round(diffMs / 1000) + const m = Math.round(diffMs / 60000) + const h = Math.round(diffMs / 3600000) + const d = Math.round(diffMs / 86400000) + const w = Math.round(diffMs / 604800000) + const mo = Math.round(diffMs / 2592000000) + const y = Math.round(diffMs / 31536000000) + if (abs(s) < 60) return rtf.format(-s, "second") + if (abs(m) < 60) return rtf.format(-m, "minute") + if (abs(h) < 24) return rtf.format(-h, "hour") + if (abs(d) < 7) return rtf.format(-d, "day") + if (abs(w) < 4) return rtf.format(-w, "week") + if (abs(mo) < 12) return rtf.format(-mo, "month") + return rtf.format(-y, "year") +} + +export default ((userOpts?: Partial) => { + const RecentChanges: QuartzComponent = ({ + allFiles, + fileData, + displayClass, + cfg, + }: QuartzComponentProps) => { + const opts = { ...defaultOptions(cfg), ...userOpts } + const t = i18n(cfg.locale).components.recentChanges + + // Only render on specified pages (empty = all pages) + if (opts.pages.length > 0 && !opts.pages.some((p) => fileData.slug === p)) { + return null + } + + // Filter files with valid dates + const validFiles = allFiles.filter( + (file: QuartzPluginData) => file.dates && (file.dates.created || file.dates.modified), + ) + + // Sort by most recent date + const sortedFiles = validFiles.sort((a: QuartzPluginData, b: QuartzPluginData) => { + const dateA = a.dates?.modified || a.dates?.created || new Date(0) + const dateB = b.dates?.modified || b.dates?.created || new Date(0) + return dateB.getTime() - dateA.getTime() + }) + + // Convert to ChangedItems + const allItems: ChangedItem[] = sortedFiles.map((file: QuartzPluginData) => { + const created = file.dates?.created + const modified = file.dates?.modified + + // A note is "Updated" only if its creation date predates the last modification by >1h. + const isModified = + created !== undefined && + modified !== undefined && + modified.getTime() - created.getTime() > 60 * 60 * 1000 + + return { + title: file.frontmatter?.title ?? i18n(cfg.locale).propertyDefaults.title, + link: file.slug as FullSlug, + date: file.dates?.modified || file.dates?.created || new Date(), + createdDate: file.dates?.created ?? file.dates?.modified ?? new Date(), + type: isModified ? "modified" : "created", + id: `${file.slug}-${isModified ? "modified" : "created"}`, + excerpt: file.frontmatter?.description, + tags: file.frontmatter?.tags, + } + }) + + // Apply type filters + let filtered = allItems + if (!opts.showCreated) { + filtered = filtered.filter((item) => item.type !== "created") + } + if (!opts.showModified) { + filtered = filtered.filter((item) => item.type !== "modified") + } + + // Apply path filters + if (opts.filterBy.length > 0) { + filtered = filtered.filter((item) => opts.filterBy.some((f) => item.link.includes(f))) + } + + const remaining = Math.max(0, filtered.length - opts.limit) + + // JSON data island: all items as compact JSON for progressive client-side injection. + // Links are pre-resolved server-side since the client cannot call resolveRelative. + // The `i` field records each item's index in this array for deduplication tracking. + const allItemsJson = JSON.stringify( + filtered.map((item, idx) => ({ + i: idx, + t: item.title, + l: resolveRelative(fileData.slug!, item.link), + d: item.date.getTime(), + c: item.createdDate.getTime(), + k: item.type, + ...(opts.showExcerpt && item.excerpt ? { e: item.excerpt } : {}), + ...(opts.showTags && item.tags?.length ? { g: item.tags } : {}), + })), + ).replace(/<\//g, "<\\/") + + // For showFilter: pre-render pageSize items per type so both "New" and "Updated" tabs + // start with visible content. Each item is tagged with its index in filtered (= allData) + // via data-idx so the client can correctly initialize its deduplication set. + // For !showFilter: flat limit cap, no Load More. + const filteredWithIdx = filtered.map((item, idx) => ({ item, idx })) + const initialItems = opts.showFilter + ? [ + ...filteredWithIdx.filter(({ item }) => item.type === "created").slice(0, opts.pageSize), + ...filteredWithIdx.filter(({ item }) => item.type === "modified").slice(0, opts.pageSize), + ].sort((a, b) => b.item.date.getTime() - a.item.date.getTime()) + : filteredWithIdx.slice(0, opts.limit) + + // i18n strings passed to the client-side script via data attributes + const i18nData = JSON.stringify({ + badgeNew: t.badgeNew, + badgeUpdated: t.badgeUpdated, + noChanges: t.noChanges, + }) + + return ( +
+

+ {opts.linkToMore ? ( + + {opts.title ?? t.title} + + ) : ( + opts.title ?? t.title + )} +

+ + {opts.showFilter && ( +
+ + + +
+ )} + + {opts.showFilter &&

} + + {filtered.length === 0 ? ( +

{t.noChanges}

+ ) : ( +
    + {initialItems.map(({ item, idx }) => ( +
  • + + {item.title} + +
    + + {item.type === "created" ? t.badgeNew : t.badgeUpdated} + + + {formatRelativeDate(item.date, cfg.locale)} + +
    + + {opts.detailed && opts.showExcerpt && item.excerpt && ( +

    {item.excerpt}

    + )} + + {opts.detailed && opts.showTags && item.tags && item.tags.length > 0 && ( +
    + {item.tags.map((tag) => ( + + {tag} + + ))} +
    + )} +
  • + ))} +
+ )} + + {opts.showFilter && ( + // @ts-ignore — dangerouslySetInnerHTML on