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
This commit is contained in:
Soushi888 2026-03-17 23:10:46 -04:00
parent 59b5807601
commit b29dc907e8
37 changed files with 1054 additions and 31 deletions

40
package-lock.json generated
View File

@ -97,13 +97,13 @@
"version": "2.10.2", "version": "2.10.2",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.10.2.tgz",
"integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==", "integrity": "sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==",
"license": "(Apache-2.0 AND BSD-3-Clause)", "license": "(Apache-2.0 AND BSD-3-Clause)"
"peer": true
}, },
"node_modules/@citation-js/core": { "node_modules/@citation-js/core": {
"version": "0.7.14", "version": "0.7.14",
"resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.14.tgz", "resolved": "https://registry.npmjs.org/@citation-js/core/-/core-0.7.14.tgz",
"integrity": "sha512-dgeGqYDSQmn2MtnWZkwPGpJQPh43yr1lAAr9jl1NJ9pIY1RXUQxtlAUZVur0V9PHdbfQC+kkvB1KC3VpgVV3MA==", "integrity": "sha512-dgeGqYDSQmn2MtnWZkwPGpJQPh43yr1lAAr9jl1NJ9pIY1RXUQxtlAUZVur0V9PHdbfQC+kkvB1KC3VpgVV3MA==",
"peer": true,
"dependencies": { "dependencies": {
"@citation-js/date": "^0.5.0", "@citation-js/date": "^0.5.0",
"@citation-js/name": "^0.4.2", "@citation-js/name": "^0.4.2",
@ -2611,8 +2611,7 @@
"version": "0.2.0", "version": "0.2.0",
"resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz", "resolved": "https://registry.npmjs.org/buffer-builder/-/buffer-builder-0.2.0.tgz",
"integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==", "integrity": "sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==",
"license": "MIT/X11", "license": "MIT/X11"
"peer": true
}, },
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
@ -2732,8 +2731,7 @@
"version": "0.5.2", "version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
"integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==", "integrity": "sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/comma-separated-tokens": { "node_modules/comma-separated-tokens": {
"version": "2.0.3", "version": "2.0.3",
@ -3106,6 +3104,7 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -3284,6 +3283,7 @@
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@ -3696,7 +3696,6 @@
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -5854,6 +5853,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz", "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==", "integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/preact" "url": "https://opencollective.com/preact"
@ -6415,7 +6415,6 @@
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.1.0" "tslib": "^2.1.0"
} }
@ -6500,7 +6499,6 @@
], ],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"sass": "1.97.2" "sass": "1.97.2"
} }
@ -6517,7 +6515,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6534,7 +6531,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6551,7 +6547,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6568,7 +6563,6 @@
"os": [ "os": [
"android" "android"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6585,7 +6579,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6602,7 +6595,6 @@
"os": [ "os": [
"darwin" "darwin"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6619,7 +6611,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6636,7 +6627,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6653,7 +6643,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6670,7 +6659,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6687,7 +6675,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6704,7 +6691,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6721,7 +6707,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6738,7 +6723,6 @@
"os": [ "os": [
"linux" "linux"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6755,7 +6739,6 @@
"!linux", "!linux",
"!win32" "!win32"
], ],
"peer": true,
"dependencies": { "dependencies": {
"sass": "1.97.2" "sass": "1.97.2"
} }
@ -6772,7 +6755,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6789,7 +6771,6 @@
"os": [ "os": [
"win32" "win32"
], ],
"peer": true,
"engines": { "engines": {
"node": ">=14.0.0" "node": ">=14.0.0"
} }
@ -6959,6 +6940,7 @@
"version": "1.26.2", "version": "1.26.2",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-1.26.2.tgz", "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.26.2.tgz",
"integrity": "sha512-iP7u2NA9A6JwRRCkIUREEX2cMhlYV5EBmbbSlfSRvPThwca8HBRbVkWuNWW+kw9+i6BSUZqqG6YeUs5dC2SjZw==", "integrity": "sha512-iP7u2NA9A6JwRRCkIUREEX2cMhlYV5EBmbbSlfSRvPThwca8HBRbVkWuNWW+kw9+i6BSUZqqG6YeUs5dC2SjZw==",
"peer": true,
"dependencies": { "dependencies": {
"@shikijs/core": "1.26.2", "@shikijs/core": "1.26.2",
"@shikijs/engine-javascript": "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", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"has-flag": "^4.0.0" "has-flag": "^4.0.0"
}, },
@ -7158,7 +7139,6 @@
"resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz",
"integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"sync-message-port": "^1.0.0" "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", "resolved": "https://registry.npmjs.org/sync-message-port/-/sync-message-port-1.1.3.tgz",
"integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==", "integrity": "sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
} }
@ -7492,8 +7471,7 @@
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/vfile": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",

View File

@ -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<Options>) => {
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 (
<div
class={classNames(displayClass, "recent-changes")}
data-page-size={opts.pageSize}
data-detailed={opts.detailed ? "1" : "0"}
data-show-excerpt={opts.showExcerpt ? "1" : "0"}
data-show-tags={opts.showTags ? "1" : "0"}
data-locale={cfg.locale}
data-i18n={i18nData}
data-load-more-tpl={t.loadMoreTemplate}
>
<h3>
{opts.linkToMore ? (
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>
{opts.title ?? t.title}
</a>
) : (
opts.title ?? t.title
)}
</h3>
{opts.showFilter && (
<div class="recent-changes-filter" role="group" aria-label="Filter changes">
<button data-filter="all" class="active">
{t.filterAll}
</button>
<button data-filter="created">{t.filterNew}</button>
<button data-filter="modified">{t.filterUpdated}</button>
</div>
)}
{opts.showFilter && <p class="rc-tab-desc" aria-live="polite"></p>}
{filtered.length === 0 ? (
<p>{t.noChanges}</p>
) : (
<ul class={`recent-changes-list ${opts.detailed ? "detailed" : "condensed"}`}>
{initialItems.map(({ item, idx }) => (
<li
key={item.id}
class={`recent-change-item ${item.type}`}
data-type={item.type}
data-idx={idx}
>
<a
href={resolveRelative(fileData.slug!, item.link)}
class="recent-change-link internal"
>
<span class="recent-change-title">{item.title}</span>
</a>
<div class="recent-change-meta">
<span class="recent-change-type">
{item.type === "created" ? t.badgeNew : t.badgeUpdated}
</span>
<span
class="recent-change-date"
data-timestamp={item.date.getTime().toString()}
>
{formatRelativeDate(item.date, cfg.locale)}
</span>
</div>
{opts.detailed && opts.showExcerpt && item.excerpt && (
<p class="recent-change-excerpt">{item.excerpt}</p>
)}
{opts.detailed && opts.showTags && item.tags && item.tags.length > 0 && (
<div class="recent-change-tags">
{item.tags.map((tag) => (
<span key={tag} class="recent-change-tag">
{tag}
</span>
))}
</div>
)}
</li>
))}
</ul>
)}
{opts.showFilter && (
// @ts-ignore — dangerouslySetInnerHTML on <script> is valid in Preact
<script
type="application/json"
class="rc-items-data"
dangerouslySetInnerHTML={{ __html: allItemsJson }}
/>
)}
{opts.showFilter && filtered.length > 0 && (
<button class="recent-changes-load-more" style="display:none">
Load more
</button>
)}
{!opts.showFilter && opts.linkToMore && remaining > 0 && (
<div class="recent-changes-more">
<a href={resolveRelative(fileData.slug!, opts.linkToMore)}>View all changes </a>
</div>
)}
</div>
)
}
RecentChanges.css = style
RecentChanges.afterDOMLoaded = script
return RecentChanges
}) satisfies QuartzComponentConstructor

View File

@ -23,6 +23,7 @@ import Breadcrumbs from "./Breadcrumbs"
import Comments from "./Comments" import Comments from "./Comments"
import Flex from "./Flex" import Flex from "./Flex"
import ConditionalRender from "./ConditionalRender" import ConditionalRender from "./ConditionalRender"
import RecentChanges from "./RecentChanges"
export { export {
ArticleTitle, ArticleTitle,
@ -50,4 +51,5 @@ export {
Comments, Comments,
Flex, Flex,
ConditionalRender, ConditionalRender,
RecentChanges,
} }

View File

@ -0,0 +1,238 @@
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")
}
interface RcItemJson {
i: number
t: string
l: string
d: number // most-recent-activity timestamp
c: number // creation date timestamp
k: "created" | "modified"
e?: string
g?: string[]
}
interface RcI18n {
badgeNew: string
badgeUpdated: string
noChanges: string
}
function setupRecentChanges() {
// Read page locale from <html lang="..."> (set by Quartz from cfg.locale)
const pageLocale = document.documentElement.lang || "en-US"
// Refresh relative dates on all pre-rendered items
const dateEls = document.querySelectorAll<HTMLElement>(".recent-change-date[data-timestamp]")
dateEls.forEach((el) => {
const ts = parseInt(el.dataset.timestamp!, 10)
if (!isNaN(ts)) {
el.textContent = formatRelativeDate(new Date(ts), pageLocale)
}
})
const containers = document.querySelectorAll<HTMLElement>(".recent-changes")
containers.forEach((container) => {
const filterGroup = container.querySelector<HTMLElement>(".recent-changes-filter")
if (!filterGroup) return
const locale = container.dataset.locale ?? pageLocale
const pageSize = parseInt(container.dataset.pageSize ?? "20", 10)
const isDetailed = container.dataset.detailed === "1"
const showExcerpt = container.dataset.showExcerpt === "1"
const showTags = container.dataset.showTags === "1"
const loadMoreTpl = container.dataset.loadMoreTpl ?? "Load {count} more · {remaining} remaining"
const i18nData: RcI18n = JSON.parse(container.dataset.i18n ?? "{}")
const list = container.querySelector<HTMLUListElement>(".recent-changes-list")
const loadMoreBtn = container.querySelector<HTMLButtonElement>(".recent-changes-load-more")
const tabDesc = container.querySelector<HTMLParagraphElement>(".rc-tab-desc")
if (!list) return
const safeList = list
const dataScript = container.querySelector<HTMLScriptElement>(".rc-items-data")
if (!dataScript) return
let allData: RcItemJson[]
try {
allData = JSON.parse(dataScript.textContent ?? "[]")
} catch {
return
}
// Three sort views:
// "all" → all notes by most recent activity
// "created" → ALL notes by creation date (the "New" tab)
// "modified" → only modified notes by modification date
const sortedArrays: Record<string, RcItemJson[]> = {
all: [...allData].sort((a, b) => b.d - a.d),
created: [...allData].sort((a, b) => b.c - a.c),
modified: allData.filter((x) => x.k === "modified").sort((a, b) => b.d - a.d),
}
// Human-readable descriptions for each tab
const tabDescriptions: Record<string, string> = {
all: `All ${allData.length} notes · most recent activity first`,
created: `All ${allData.length} notes · sorted by when they were added`,
modified: `${sortedArrays.modified.length} revised notes · latest changes first`,
}
// Per-tab injection pointer
const injectedCount: Record<string, number> = { all: 0, created: 0, modified: 0 }
let currentFilter = localStorage.getItem("recent-changes-filter") ?? "all"
function createItemEl(item: RcItemJson, filter: string): HTMLLIElement {
const li = document.createElement("li")
li.className = `recent-change-item ${item.k}`
li.dataset.type = item.k
const a = document.createElement("a")
a.href = item.l
a.className = "recent-change-link internal"
const titleSpan = document.createElement("span")
titleSpan.className = "recent-change-title"
titleSpan.textContent = item.t
a.appendChild(titleSpan)
li.appendChild(a)
const meta = document.createElement("div")
meta.className = "recent-change-meta"
const typeSpan = document.createElement("span")
typeSpan.className = "recent-change-type"
typeSpan.textContent =
item.k === "created" ? (i18nData.badgeNew ?? "New") : (i18nData.badgeUpdated ?? "Edited")
meta.appendChild(typeSpan)
// Use creation timestamp for the "New" tab, activity timestamp otherwise
const ts = filter === "created" ? item.c : item.d
const dateSpan = document.createElement("span")
dateSpan.className = "recent-change-date"
dateSpan.dataset.timestamp = ts.toString()
dateSpan.textContent = formatRelativeDate(new Date(ts), locale)
meta.appendChild(dateSpan)
li.appendChild(meta)
if (isDetailed && showExcerpt && item.e) {
const p = document.createElement("p")
p.className = "recent-change-excerpt"
p.textContent = item.e
li.appendChild(p)
}
if (isDetailed && showTags && item.g?.length) {
const tagsDiv = document.createElement("div")
tagsDiv.className = "recent-change-tags"
item.g.forEach((tag) => {
const tagSpan = document.createElement("span")
tagSpan.className = "recent-change-tag"
tagSpan.textContent = tag
tagsDiv.appendChild(tagSpan)
})
li.appendChild(tagsDiv)
}
return li
}
function updateTabDesc(filter: string) {
if (tabDesc) tabDesc.textContent = tabDescriptions[filter] ?? ""
}
function updateLoadMoreBtn(filter: string) {
if (!loadMoreBtn) return
const arr = sortedArrays[filter] ?? []
const loaded = injectedCount[filter]
const remaining = arr.length - loaded
if (remaining <= 0) {
loadMoreBtn.style.display = "none"
} else {
const count = Math.min(pageSize, remaining)
loadMoreBtn.textContent = loadMoreTpl
.replace("{count}", String(count))
.replace("{remaining}", String(remaining))
loadMoreBtn.style.display = "block"
}
}
function renderTab(filter: string) {
safeList.innerHTML = ""
injectedCount[filter] = 0
const arr = sortedArrays[filter] ?? []
const end = Math.min(pageSize, arr.length)
for (let i = 0; i < end; i++) {
safeList.appendChild(createItemEl(arr[i], filter))
}
injectedCount[filter] = end
updateTabDesc(filter)
updateLoadMoreBtn(filter)
}
function loadMore(filter: string) {
const arr = sortedArrays[filter] ?? []
const start = injectedCount[filter]
const end = Math.min(start + pageSize, arr.length)
for (let i = start; i < end; i++) {
safeList.appendChild(createItemEl(arr[i], filter))
}
injectedCount[filter] = end
updateLoadMoreBtn(filter)
}
const buttons = filterGroup.querySelectorAll<HTMLButtonElement>("button[data-filter]")
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
currentFilter = btn.dataset.filter ?? "all"
localStorage.setItem("recent-changes-filter", currentFilter)
buttons.forEach((b) => b.classList.toggle("active", b === btn))
renderTab(currentFilter)
})
})
if (loadMoreBtn) {
loadMoreBtn.addEventListener("click", () => loadMore(currentFilter))
}
// Restore saved filter button active state
if (currentFilter !== "all") {
const savedBtn = filterGroup.querySelector<HTMLButtonElement>(
`button[data-filter="${currentFilter}"]`,
)
if (savedBtn) {
buttons.forEach((b) => b.classList.remove("active"))
savedBtn.classList.add("active")
}
}
// Initialize: keep SSR items if on "all" tab, otherwise rebuild
if (currentFilter === "all") {
injectedCount.all = safeList.querySelectorAll(".recent-change-item").length
updateTabDesc("all")
updateLoadMoreBtn("all")
} else {
renderTab(currentFilter)
}
})
}
document.addEventListener("nav", setupRecentChanges)

View File

@ -0,0 +1,214 @@
.recent-changes {
margin: 1.5rem 0;
// Badge colors (customizable via CSS custom properties)
--rc-created-bg: #b3e6cc;
--rc-created-text: #005500;
--rc-modified-bg: #d0e0f0;
--rc-modified-text: #003366;
h3 {
font-size: 1.3rem;
margin-bottom: 0.8rem;
color: var(--secondary);
}
.recent-changes-filter {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
button {
padding: 0.35rem 0.75rem;
border: 1px solid var(--lightgray);
border-radius: 4px;
background: transparent;
color: var(--darkgray);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s;
&.active {
background: var(--secondary);
color: var(--light);
border-color: var(--secondary);
}
&:hover:not(.active) {
background: var(--lightgray);
}
}
}
.rc-tab-desc {
font-size: 0.78rem;
color: var(--gray);
margin: -0.5rem 0 0.75rem;
min-height: 1.1em;
}
.rc-hidden-filter,
.rc-hidden-page {
display: none !important;
}
.recent-changes-load-more {
display: block;
margin: 1rem auto;
padding: 0.5rem 1.5rem;
border: 1px solid var(--lightgray);
border-radius: 4px;
background: transparent;
color: var(--secondary);
font-family: inherit;
font-size: 0.85rem;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: var(--lightgray);
}
}
.recent-changes-list {
list-style-type: none;
padding: 0;
margin: 0;
&.detailed {
.recent-change-item {
margin-bottom: 1.5rem;
padding-bottom: 1.2rem;
border-bottom: 1px solid var(--lightgray);
&:last-child {
border-bottom: none;
}
}
.recent-change-excerpt {
margin: 0.5rem 0;
font-size: 0.9rem;
color: var(--gray);
line-height: 1.4;
}
.recent-change-tags {
margin-top: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
.recent-change-tag {
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
background-color: var(--lightgray);
border-radius: 4px;
color: var(--darkgray);
}
}
}
&.condensed {
.recent-change-item {
margin-bottom: 0.5rem;
display: flex;
flex-direction: column;
@media (min-width: 768px) {
flex-direction: row;
align-items: baseline;
justify-content: space-between;
}
}
}
}
.recent-change-item {
position: relative;
&.created {
.recent-change-type {
background-color: var(--rc-created-bg);
color: var(--rc-created-text);
}
}
&.modified {
.recent-change-type {
background-color: var(--rc-modified-bg);
color: var(--rc-modified-text);
}
}
}
.recent-change-link {
font-weight: 500;
text-decoration: none;
color: var(--dark);
&:hover {
text-decoration: underline;
color: var(--secondary);
}
}
.recent-change-meta {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.25rem;
font-size: 0.8rem;
@media (min-width: 768px) {
.condensed & {
margin-top: 0;
}
}
}
.recent-change-type {
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.recent-change-date {
color: var(--gray);
}
.recent-changes-more {
margin-top: 1rem;
text-align: right;
a {
font-size: 0.85rem;
text-decoration: none;
color: var(--secondary);
&:hover {
text-decoration: underline;
}
}
}
}
// Dark mode adjustments
@media (prefers-color-scheme: dark) {
.recent-changes {
--rc-created-bg: rgba(179, 230, 204, 0.2);
--rc-created-text: #b3e6cc;
--rc-modified-bg: rgba(208, 224, 240, 0.2);
--rc-modified-text: #d0e0f0;
.recent-changes-list.detailed .recent-change-tags .recent-change-tag {
background-color: var(--darkgray);
color: var(--light);
}
}
}

View File

@ -0,0 +1,12 @@
import { FullSlug } from "../../util/path"
export interface ChangedItem {
title: string
link: FullSlug
date: Date // most recent activity (used for "All" and "Updated" sort)
createdDate: Date // first known date (used for "New" sort)
type: "created" | "modified" // display badge only — not a filter
excerpt?: string
tags?: string[]
id: string
}

View File

@ -46,6 +46,16 @@ export default {
title: "آخر الملاحظات", title: "آخر الملاحظات",
seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`, seeRemainingMore: ({ remaining }) => `تصفح ${remaining} أكثر →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`, transcludeOf: ({ targetSlug }) => `مقتبس من ${targetSlug}`,
linkToOriginal: "وصلة للملاحظة الرئيسة", linkToOriginal: "وصلة للملاحظة الرئيسة",

View File

@ -45,6 +45,16 @@ export default {
title: "Notes Recents", title: "Notes Recents",
seeRemainingMore: ({ remaining }) => `Vegi ${remaining} més →`, seeRemainingMore: ({ remaining }) => `Vegi ${remaining} més →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transcluit de ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transcluit de ${targetSlug}`,
linkToOriginal: "Enllaç a l'original", linkToOriginal: "Enllaç a l'original",

View File

@ -45,6 +45,16 @@ export default {
title: "Nejnovější poznámky", title: "Nejnovější poznámky",
seeRemainingMore: ({ remaining }) => `Zobraz ${remaining} dalších →`, seeRemainingMore: ({ remaining }) => `Zobraz ${remaining} dalších →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Zobrazení ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Zobrazení ${targetSlug}`,
linkToOriginal: "Odkaz na původní dokument", linkToOriginal: "Odkaz na původní dokument",

View File

@ -45,6 +45,16 @@ export default {
title: "Zuletzt bearbeitete Seiten", title: "Zuletzt bearbeitete Seiten",
seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`, seeRemainingMore: ({ remaining }) => `${remaining} weitere ansehen →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transklusion von ${targetSlug}`,
linkToOriginal: "Link zum Original", linkToOriginal: "Link zum Original",

View File

@ -48,6 +48,16 @@ export interface Translation {
title: string title: string
seeRemainingMore: (variables: { remaining: number }) => string seeRemainingMore: (variables: { remaining: number }) => string
} }
recentChanges: {
title: string
filterAll: string
filterNew: string
filterUpdated: string
loadMoreTemplate: string
noChanges: string
badgeNew: string
badgeUpdated: string
}
transcludes: { transcludes: {
transcludeOf: (variables: { targetSlug: FullSlug }) => string transcludeOf: (variables: { targetSlug: FullSlug }) => string
linkToOriginal: string linkToOriginal: string

View File

@ -45,6 +45,16 @@ export default {
title: "Recent Notes", title: "Recent Notes",
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`, seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
linkToOriginal: "Link to original", linkToOriginal: "Link to original",

View File

@ -45,6 +45,16 @@ export default {
title: "Recent Notes", title: "Recent Notes",
seeRemainingMore: ({ remaining }) => `See ${remaining} more →`, seeRemainingMore: ({ remaining }) => `See ${remaining} more →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transclude of ${targetSlug}`,
linkToOriginal: "Link to original", linkToOriginal: "Link to original",

View File

@ -45,6 +45,16 @@ export default {
title: "Notas Recientes", title: "Notas Recientes",
seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`, seeRemainingMore: ({ remaining }) => `Vea ${remaining} más →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transcluido de ${targetSlug}`,
linkToOriginal: "Enlace al original", linkToOriginal: "Enlace al original",

View File

@ -46,6 +46,16 @@ export default {
title: "یادداشت‌های اخیر", title: "یادداشت‌های اخیر",
seeRemainingMore: ({ remaining }) => `${remaining} یادداشت دیگر →`, seeRemainingMore: ({ remaining }) => `${remaining} یادداشت دیگر →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `از ${targetSlug}`, transcludeOf: ({ targetSlug }) => `از ${targetSlug}`,
linkToOriginal: "پیوند به اصلی", linkToOriginal: "پیوند به اصلی",

View File

@ -45,6 +45,16 @@ export default {
title: "Viimeisimmät muistiinpanot", title: "Viimeisimmät muistiinpanot",
seeRemainingMore: ({ remaining }) => `Näytä ${remaining} lisää →`, seeRemainingMore: ({ remaining }) => `Näytä ${remaining} lisää →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Upote kohteesta ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Upote kohteesta ${targetSlug}`,
linkToOriginal: "Linkki alkuperäiseen", linkToOriginal: "Linkki alkuperäiseen",

View File

@ -45,6 +45,16 @@ export default {
title: "Notes Récentes", title: "Notes Récentes",
seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`, seeRemainingMore: ({ remaining }) => `Voir ${remaining} de plus →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transclusion de ${targetSlug}`,
linkToOriginal: "Lien vers l'original", linkToOriginal: "Lien vers l'original",

View File

@ -46,6 +46,16 @@ export default {
title: "הערות אחרונות", title: "הערות אחרונות",
seeRemainingMore: ({ remaining }) => `עיין ב ${remaining} נוספים →`, seeRemainingMore: ({ remaining }) => `עיין ב ${remaining} נוספים →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `מצוטט מ ${targetSlug}`, transcludeOf: ({ targetSlug }) => `מצוטט מ ${targetSlug}`,
linkToOriginal: "קישור למקורי", linkToOriginal: "קישור למקורי",

View File

@ -45,6 +45,16 @@ export default {
title: "Legutóbbi jegyzetek", title: "Legutóbbi jegyzetek",
seeRemainingMore: ({ remaining }) => `${remaining} további megtekintése →`, seeRemainingMore: ({ remaining }) => `${remaining} további megtekintése →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug} áthivatkozása`, transcludeOf: ({ targetSlug }) => `${targetSlug} áthivatkozása`,
linkToOriginal: "Hivatkozás az eredetire", linkToOriginal: "Hivatkozás az eredetire",

View File

@ -45,6 +45,16 @@ export default {
title: "Catatan Terbaru", title: "Catatan Terbaru",
seeRemainingMore: ({ remaining }) => `Lihat ${remaining} lagi →`, seeRemainingMore: ({ remaining }) => `Lihat ${remaining} lagi →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transklusi dari ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transklusi dari ${targetSlug}`,
linkToOriginal: "Tautan ke asli", linkToOriginal: "Tautan ke asli",

View File

@ -46,6 +46,16 @@ export default {
seeRemainingMore: ({ remaining }) => seeRemainingMore: ({ remaining }) =>
remaining === 1 ? "Vedi 1 altra →" : `Vedi altre ${remaining}`, remaining === 1 ? "Vedi 1 altra →" : `Vedi altre ${remaining}`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Inclusione di ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Inclusione di ${targetSlug}`,
linkToOriginal: "Link all'originale", linkToOriginal: "Link all'originale",

View File

@ -45,6 +45,16 @@ export default {
title: "最近の記事", title: "最近の記事",
seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`, seeRemainingMore: ({ remaining }) => `さらに${remaining}件 →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`, transcludeOf: ({ targetSlug }) => `${targetSlug}のまとめ`,
linkToOriginal: "元記事へのリンク", linkToOriginal: "元記事へのリンク",

View File

@ -45,6 +45,16 @@ export default {
title: "Соңғы жазбалар", title: "Соңғы жазбалар",
seeRemainingMore: ({ remaining }) => `Тағы ${remaining} жазбаны қарау →`, seeRemainingMore: ({ remaining }) => `Тағы ${remaining} жазбаны қарау →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug} кірістіру`, transcludeOf: ({ targetSlug }) => `${targetSlug} кірістіру`,
linkToOriginal: "Бастапқыға сілтеме", linkToOriginal: "Бастапқыға сілтеме",

View File

@ -45,6 +45,16 @@ export default {
title: "최근 게시글", title: "최근 게시글",
seeRemainingMore: ({ remaining }) => `${remaining}건 더보기 →`, seeRemainingMore: ({ remaining }) => `${remaining}건 더보기 →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug}의 포함`, transcludeOf: ({ targetSlug }) => `${targetSlug}의 포함`,
linkToOriginal: "원본 링크", linkToOriginal: "원본 링크",

View File

@ -45,6 +45,16 @@ export default {
title: "Naujausi Užrašai", title: "Naujausi Užrašai",
seeRemainingMore: ({ remaining }) => `Peržiūrėti dar ${remaining}`, seeRemainingMore: ({ remaining }) => `Peržiūrėti dar ${remaining}`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Įterpimas iš ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Įterpimas iš ${targetSlug}`,
linkToOriginal: "Nuoroda į originalą", linkToOriginal: "Nuoroda į originalą",

View File

@ -45,6 +45,16 @@ export default {
title: "Nylige notater", title: "Nylige notater",
seeRemainingMore: ({ remaining }) => `Se ${remaining} til →`, seeRemainingMore: ({ remaining }) => `Se ${remaining} til →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transkludering of ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transkludering of ${targetSlug}`,
linkToOriginal: "Lenke til original", linkToOriginal: "Lenke til original",

View File

@ -45,6 +45,16 @@ export default {
title: "Recente notities", title: "Recente notities",
seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`, seeRemainingMore: ({ remaining }) => `Zie ${remaining} meer →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Invoeging van ${targetSlug}`,
linkToOriginal: "Link naar origineel", linkToOriginal: "Link naar origineel",

View File

@ -45,6 +45,16 @@ export default {
title: "Najnowsze notatki", title: "Najnowsze notatki",
seeRemainingMore: ({ remaining }) => `Zobacz ${remaining} nastepnych →`, seeRemainingMore: ({ remaining }) => `Zobacz ${remaining} nastepnych →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Osadzone ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Osadzone ${targetSlug}`,
linkToOriginal: "Łącze do oryginału", linkToOriginal: "Łącze do oryginału",

View File

@ -45,6 +45,16 @@ export default {
title: "Notas recentes", title: "Notas recentes",
seeRemainingMore: ({ remaining }) => `Veja mais ${remaining}`, seeRemainingMore: ({ remaining }) => `Veja mais ${remaining}`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Transcrever de ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Transcrever de ${targetSlug}`,
linkToOriginal: "Link ao original", linkToOriginal: "Link ao original",

View File

@ -45,6 +45,16 @@ export default {
title: "Notițe recente", title: "Notițe recente",
seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining}`, seeRemainingMore: ({ remaining }) => `Vezi încă ${remaining}`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Extras din ${targetSlug}`,
linkToOriginal: "Legătură către original", linkToOriginal: "Legătură către original",

View File

@ -46,6 +46,16 @@ export default {
seeRemainingMore: ({ remaining }) => seeRemainingMore: ({ remaining }) =>
`Посмотреть оставш${getForm(remaining, "уюся", "иеся", "иеся")} ${remaining}`, `Посмотреть оставш${getForm(remaining, "уюся", "иеся", "иеся")} ${remaining}`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Переход из ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Переход из ${targetSlug}`,
linkToOriginal: "Ссылка на оригинал", linkToOriginal: "Ссылка на оригинал",

View File

@ -45,6 +45,16 @@ export default {
title: "บันทึกล่าสุด", title: "บันทึกล่าสุด",
seeRemainingMore: ({ remaining }) => `ดูเพิ่มอีก ${remaining} รายการ →`, seeRemainingMore: ({ remaining }) => `ดูเพิ่มอีก ${remaining} รายการ →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `รวมข้ามเนื้อหาจาก ${targetSlug}`, transcludeOf: ({ targetSlug }) => `รวมข้ามเนื้อหาจาก ${targetSlug}`,
linkToOriginal: "ดูหน้าต้นทาง", linkToOriginal: "ดูหน้าต้นทาง",

View File

@ -45,6 +45,16 @@ export default {
title: "Son Notlar", title: "Son Notlar",
seeRemainingMore: ({ remaining }) => `${remaining} tane daha gör →`, seeRemainingMore: ({ remaining }) => `${remaining} tane daha gör →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `${targetSlug} sayfasından alıntı`, transcludeOf: ({ targetSlug }) => `${targetSlug} sayfasından alıntı`,
linkToOriginal: "Orijinal bağlantı", linkToOriginal: "Orijinal bağlantı",

View File

@ -45,6 +45,16 @@ export default {
title: "Останні нотатки", title: "Останні нотатки",
seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining}`, seeRemainingMore: ({ remaining }) => `Переглянути ще ${remaining}`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Видобуто з ${targetSlug}`,
linkToOriginal: "Посилання на оригінал", linkToOriginal: "Посилання на оригінал",

View File

@ -45,6 +45,16 @@ export default {
title: "Ghi chú gần đây", title: "Ghi chú gần đây",
seeRemainingMore: ({ remaining }) => `Xem thêm ${remaining} ghi chú →`, seeRemainingMore: ({ remaining }) => `Xem thêm ${remaining} ghi chú →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `Trích dẫn toàn bộ từ ${targetSlug}`, transcludeOf: ({ targetSlug }) => `Trích dẫn toàn bộ từ ${targetSlug}`,
linkToOriginal: "Xem trang gốc", linkToOriginal: "Xem trang gốc",

View File

@ -45,6 +45,16 @@ export default {
title: "最近的笔记", title: "最近的笔记",
seeRemainingMore: ({ remaining }) => `查看更多${remaining}篇笔记 →`, seeRemainingMore: ({ remaining }) => `查看更多${remaining}篇笔记 →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `包含${targetSlug}`, transcludeOf: ({ targetSlug }) => `包含${targetSlug}`,
linkToOriginal: "指向原始笔记的链接", linkToOriginal: "指向原始笔记的链接",

View File

@ -45,6 +45,16 @@ export default {
title: "最近的筆記", title: "最近的筆記",
seeRemainingMore: ({ remaining }) => `查看更多 ${remaining} 篇筆記 →`, seeRemainingMore: ({ remaining }) => `查看更多 ${remaining} 篇筆記 →`,
}, },
recentChanges: {
title: "Recent Changes",
filterAll: "All",
filterNew: "New",
filterUpdated: "Updated",
loadMoreTemplate: "Load {count} more · {remaining} remaining",
noChanges: "No recent changes found.",
badgeNew: "New",
badgeUpdated: "Edited",
},
transcludes: { transcludes: {
transcludeOf: ({ targetSlug }) => `包含 ${targetSlug}`, transcludeOf: ({ targetSlug }) => `包含 ${targetSlug}`,
linkToOriginal: "指向原始筆記的連結", linkToOriginal: "指向原始筆記的連結",