Versionsverlauf
Versionsverlauf für Dokumente. Versionsliste mit markierter Vorschau und Wiederherstellen, zwei Versionen nebeneinander, Änderungen im Text mit Navigation, benannte Versionen und ein sorgfältiges Wiederherstellen.
Eine von 3 Components in Import, Export und Verlauf
Variants
Details
Was Versionsverlauf mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Import, Export und Verlauf
- Bereich
- App
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/version-history.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 55 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle VersionHistoryProps aus components/ui/version-history.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalVersionHistoryVarianttimelineside-by-sideinline-diffnamed-versionsrestore
sidebaroptionalOmit<SidebarProps, 'children'> | nullThe app frame. Leave it out (or null) for a standalone screen.
titlePflichtstringsubtitleoptionalstringnowoptionalstringISO date and time that counts as now
versionsoptionalDocVersion[]Newest first. The first one is the current version.
defaultVersionoptionalstringThe version that starts out picked
onActionoptional(action: string, data: Record<string, unknown>) => void | Promise<void>Called on restore, rename and compare. Without it the block simulates the request.
Eingebaute Texte
Diese Texte schreibt Versionsverlauf selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 55 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| versions | Englisch Versions | Deutsch Versionen |
| current | Englisch Current version | Deutsch Aktuelle Version |
| named | Englisch Named | Deutsch Benannt |
| auto | Englisch Saved automatically | Deutsch Automatisch gespeichert |
| onlyNamed | Englisch Only named versions | Deutsch Nur benannte Versionen |
| restore | Englisch Restore this version | Deutsch Diese Version wiederherstellen |
| restoring | Englisch Restoring… | Deutsch Wird wiederhergestellt… |
| restored | Englisch Restored. The version from {when} is now the current one. | Deutsch Wiederhergestellt. Die Version von {when} ist jetzt die aktuelle. |
| undo | Englisch Undo | Deutsch Rückgängig |
| changes | Englisch {add} added, {del} removed | Deutsch {add} hinzugefügt, {del} entfernt |
| noChanges | Englisch No changes to the version before. | Deutsch Keine Änderungen zur Version davor. |
| showChanges | Englisch Show changes | Deutsch Änderungen zeigen |
| before | Englisch Before | Deutsch Vorher |
| after | Englisch After | Deutsch Nachher |
| older | Englisch Older version | Deutsch Ältere Version |
| newer | Englisch Newer version | Deutsch Neuere Version |
| compare | Englisch Compare | Deutsch Vergleichen |
| with | Englisch with | Deutsch mit |
| swap | Englisch Swap versions | Deutsch Versionen tauschen |
| unified | Englisch One column | Deutsch Eine Spalte |
| split | Englisch Two columns | Deutsch Zwei Spalten |
| view | Englisch View | Deutsch Ansicht |
| line | Englisch Line {n} | Deutsch Zeile {n} |
| unchanged | Englisch {n} unchanged paragraphs | Deutsch {n} unveränderte Absätze |
| expand | Englisch Show {n} unchanged paragraphs | Deutsch {n} unveränderte Absätze zeigen |
| change | Englisch Change {n} of {total} | Deutsch Änderung {n} von {total} |
| prevChange | Englisch Previous change | Deutsch Vorherige Änderung |
| nextChange | Englisch Next change | Deutsch Nächste Änderung |
| added | Englisch Added | Deutsch Hinzugefügt |
| changedLabel | Englisch Changed | Deutsch Geändert |
| removed | Englisch Removed | Deutsch Entfernt |
| words | Englisch {n} words | Deutsch {n} Wörter |
| nameIt | Englisch Name this version | Deutsch Version benennen |
| nameHint | Englisch A name keeps a version forever. Unnamed ones are merged after 30 days. | Deutsch Ein Name hält eine Version für immer. Unbenannte werden nach 30 Tagen zusammengefasst. |
| namePlaceholder | Englisch e.g. Sent to client | Deutsch zum Beispiel An Kunden geschickt |
| save | Englisch Save name | Deutsch Namen speichern |
| rename | Englisch Rename {name} | Deutsch {name} umbenennen |
| pickTwo | Englisch Pick two versions to compare | Deutsch Zwei Versionen zum Vergleichen wählen |
| pickedOne | Englisch Pick one more version | Deutsch Noch eine Version wählen |
| compareTwo | Englisch Compare these two | Deutsch Diese zwei vergleichen |
| pick | Englisch Pick {name} for comparing | Deutsch {name} zum Vergleichen wählen |
| whatChanges | Englisch What changes | Deutsch Was sich ändert |
| restoreTitle | Englisch Restore the version from {when}? | Deutsch Version von {when} wiederherstellen? |
| restoreNamed | Englisch Restore “{name}”? | Deutsch „{name}“ wiederherstellen? |
| restoreText | Englisch The current text is kept as a version of its own, so nothing gets lost. | Deutsch Der aktuelle Text bleibt als eigene Version erhalten, nichts geht verloren. |
| asNew | Englisch Restore as the current version | Deutsch Als aktuelle Version wiederherstellen |
| asNewText | Englisch Everyone sees the restored text right away. | Deutsch Alle sehen den wiederhergestellten Text sofort. |
| asCopy | Englisch Restore as a copy | Deutsch Als Kopie wiederherstellen |
| asCopyText | Englisch A new document next to this one, the current text stays. | Deutsch Ein neues Dokument neben diesem, der aktuelle Text bleibt. |
| paragraphsChanged | Englisch {n} paragraphs change | Deutsch {n} Absätze ändern sich |
| copyMade | Englisch Copy created as “{name} (copy)”. | Deutsch Kopie als „{name} (Kopie)“ angelegt. |
| by | Englisch by {name} | Deutsch von {name} |
| today | Englisch Today | Deutsch Heute |
| yesterday | Englisch Yesterday | Deutsch Gestern |
| previewOf | Englisch Version from {when} | Deutsch Version von {when} |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Versionsverlauf reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked, aria-current, aria-label, aria-labelledby und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio, radiogroup und status.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 1.183 Zeilen
'use client';
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { cn, type Locale, prefersReducedMotion, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Dialog } from '@/components/ui/dialog';
import { Sidebar, type SidebarProps } from '@/components/ui/sidebar';
export type VersionHistoryVariant = 'timeline' | 'side-by-side' | 'inline-diff' | 'named-versions' | 'restore';
export interface DocVersion {
id: string;
/** Who saved it */
author: string;
/** ISO date and time */
time: string;
/** A name makes it a named version, e.g. Sent to client */
name?: string;
/** The document as paragraphs. The first one is shown as the title. */
content: string[];
/** Saved by the app on its own */
auto?: boolean;
}
export interface VersionHistoryProps {
variant?: VersionHistoryVariant;
/** The app frame. Leave it out (or null) for a standalone screen. */
sidebar?: Omit<SidebarProps, 'children'> | null;
title: string;
subtitle?: string;
/** ISO date and time that counts as now */
now?: string;
/** Newest first. The first one is the current version. */
versions?: DocVersion[];
/** The version that starts out picked */
defaultVersion?: string;
/** Called on restore, rename and compare. Without it the block simulates the request. */
onAction?: (action: string, data: Record<string, unknown>) => void | Promise<void>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
versions: 'Versions',
current: 'Current version',
named: 'Named',
auto: 'Saved automatically',
onlyNamed: 'Only named versions',
restore: 'Restore this version',
restoring: 'Restoring…',
restored: 'Restored. The version from {when} is now the current one.',
undo: 'Undo',
changes: '{add} added, {del} removed',
noChanges: 'No changes to the version before.',
showChanges: 'Show changes',
before: 'Before',
after: 'After',
older: 'Older version',
newer: 'Newer version',
compare: 'Compare',
with: 'with',
swap: 'Swap versions',
unified: 'One column',
split: 'Two columns',
view: 'View',
line: 'Line {n}',
unchanged: '{n} unchanged paragraphs',
expand: 'Show {n} unchanged paragraphs',
change: 'Change {n} of {total}',
prevChange: 'Previous change',
nextChange: 'Next change',
added: 'Added',
changedLabel: 'Changed',
removed: 'Removed',
words: '{n} words',
nameIt: 'Name this version',
nameHint: 'A name keeps a version forever. Unnamed ones are merged after 30 days.',
namePlaceholder: 'e.g. Sent to client',
save: 'Save name',
rename: 'Rename {name}',
pickTwo: 'Pick two versions to compare',
pickedOne: 'Pick one more version',
compareTwo: 'Compare these two',
pick: 'Pick {name} for comparing',
whatChanges: 'What changes',
restoreTitle: 'Restore the version from {when}?',
restoreNamed: 'Restore “{name}”?',
restoreText: 'The current text is kept as a version of its own, so nothing gets lost.',
asNew: 'Restore as the current version',
asNewText: 'Everyone sees the restored text right away.',
asCopy: 'Restore as a copy',
asCopyText: 'A new document next to this one, the current text stays.',
paragraphsChanged: '{n} paragraphs change',
copyMade: 'Copy created as “{name} (copy)”.',
by: 'by {name}',
today: 'Today',
yesterday: 'Yesterday',
previewOf: 'Version from {when}',
},
de: {
versions: 'Versionen',
current: 'Aktuelle Version',
named: 'Benannt',
auto: 'Automatisch gespeichert',
onlyNamed: 'Nur benannte Versionen',
restore: 'Diese Version wiederherstellen',
restoring: 'Wird wiederhergestellt…',
restored: 'Wiederhergestellt. Die Version von {when} ist jetzt die aktuelle.',
undo: 'Rückgängig',
changes: '{add} hinzugefügt, {del} entfernt',
noChanges: 'Keine Änderungen zur Version davor.',
showChanges: 'Änderungen zeigen',
before: 'Vorher',
after: 'Nachher',
older: 'Ältere Version',
newer: 'Neuere Version',
compare: 'Vergleichen',
with: 'mit',
swap: 'Versionen tauschen',
unified: 'Eine Spalte',
split: 'Zwei Spalten',
view: 'Ansicht',
line: 'Zeile {n}',
unchanged: '{n} unveränderte Absätze',
expand: '{n} unveränderte Absätze zeigen',
change: 'Änderung {n} von {total}',
prevChange: 'Vorherige Änderung',
nextChange: 'Nächste Änderung',
added: 'Hinzugefügt',
changedLabel: 'Geändert',
removed: 'Entfernt',
words: '{n} Wörter',
nameIt: 'Version benennen',
nameHint: 'Ein Name hält eine Version für immer. Unbenannte werden nach 30 Tagen zusammengefasst.',
namePlaceholder: 'zum Beispiel An Kunden geschickt',
save: 'Namen speichern',
rename: '{name} umbenennen',
pickTwo: 'Zwei Versionen zum Vergleichen wählen',
pickedOne: 'Noch eine Version wählen',
compareTwo: 'Diese zwei vergleichen',
pick: '{name} zum Vergleichen wählen',
whatChanges: 'Was sich ändert',
restoreTitle: 'Version von {when} wiederherstellen?',
restoreNamed: '„{name}“ wiederherstellen?',
restoreText: 'Der aktuelle Text bleibt als eigene Version erhalten, nichts geht verloren.',
asNew: 'Als aktuelle Version wiederherstellen',
asNewText: 'Alle sehen den wiederhergestellten Text sofort.',
asCopy: 'Als Kopie wiederherstellen',
asCopyText: 'Ein neues Dokument neben diesem, der aktuelle Text bleibt.',
paragraphsChanged: '{n} Absätze ändern sich',
copyMade: 'Kopie als „{name} (Kopie)“ angelegt.',
by: 'von {name}',
today: 'Heute',
yesterday: 'Gestern',
previewOf: 'Version von {when}',
},
};
type Texts = (typeof TEXTS)['en'];
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/* ------------------------------------------------------------------ */
/* Motion of the app screens */
/* ------------------------------------------------------------------ */
/*
* The same small set in every app block (auth, dashboard, files, inbox …),
* kept in the block so it ships on its own:
* useEnter entrance that waits until its area scrolls in. The area gets
* data-kitm-scope and data-kitm={state}, children take kitm-wipe
* (charts draw from the left), kitm-grow-x, kitm-grow-y (bars),
* kitm-up and kitm-pop, with --kitm-d as delay.
* useGlide a marker inside the active item ([data-glide]) glides over
* from where the previous one stood: tabs, segments, steps.
* kitm-in, kitm-fly, kitm-tick for things that appear after an action.
* The server renders the end state, without JavaScript nothing waits longer
* than 2.5 s, with reduced motion nothing moves. Only transform, opacity and
* clip-path change, so no layout shifts.
*/
const useIso = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const KITM_EASE = 'cubic-bezier(0.2, 0.7, 0.2, 1)';
const KITM_FX: [string, string, number][] = [
['wipe', 'clip-path:inset(0 100% 0 0)', 1100],
['grow-x', 'transform:scaleX(0)', 900],
['grow-y', 'transform:scaleY(0)', 800],
['up', 'opacity:0;transform:translateY(10px)', 600],
['pop', 'opacity:0;transform:scale(.92)', 450],
];
const KITM_CSS =
KITM_FX.map(([k, v]) => `@keyframes kitm-${k}{from{${v}}}@keyframes kitm-${k}-h{from{${v}}}`).join('') +
'@keyframes kitm-fly{from{opacity:0;transform:translateX(24px)}}@keyframes kitm-sheen{to{transform:translateX(130%)}}.kitm-sheen{transform:translateX(-130%)}@keyframes kitm-blink{50%{opacity:0}}@keyframes kitm-shake{20%,60%{transform:translateX(-5px)}40%,80%{transform:translateX(5px)}}@keyframes kitm-ring{20%{transform:rotate(14deg)}40%{transform:rotate(-11deg)}60%{transform:rotate(7deg)}80%{transform:rotate(-3deg)}}@keyframes kitm-next{from{opacity:0;transform:translateX(18px)}}@keyframes kitm-prev{from{opacity:0;transform:translateX(-18px)}}.kitm-caret{display:inline-block;width:.45em;height:1.1em;margin-left:.15em;vertical-align:-.2em;border-radius:2px;background:color-mix(in oklab,var(--primary) 80%,var(--foreground))}@keyframes kitm-flash{from{background-color:color-mix(in oklab,var(--primary) 14%,transparent)}}' +
'.kitm-grow-x{transform-origin:left}.kitm-grow-y{transform-origin:bottom}' +
'@media (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k, , d]) => `[data-kitm=play] .kitm-${k}{animation:kitm-${k} var(--kitm-t,${d}ms) var(--ease-kit) var(--kitm-d,0ms) both}[data-kitm=wait] .kitm-${k}{animation:kitm-${k}-h 0s 1s both paused}`).join('') +
'[data-kitm=play] .kitm-sheen{animation:kitm-sheen 1.6s var(--ease-kit) var(--kitm-d,400ms) both}.kitm-in{animation:kitm-up var(--kitm-t,.5s) var(--ease-kit) var(--kitm-d,0ms) both}.kitm-fly{animation:kitm-fly .55s var(--ease-kit) var(--kitm-d,0ms) both}.kitm-tick{animation:kitm-wipe .36s var(--ease-kit) both}.kitm-caret{animation:kitm-blink 1.05s steps(1) infinite}.kitm-shake{animation:kitm-shake .4s ease-in-out}.kitm-ring{transform-origin:50% 8%;animation:kitm-ring .9s var(--ease-kit) both}.kitm-next{animation:kitm-next .45s var(--ease-kit) both}.kitm-prev{animation:kitm-prev .45s var(--ease-kit) both}.kitm-flash{animation:kitm-flash 1.6s ease-out both}' +
'}@media (scripting:enabled) and (prefers-reduced-motion:no-preference){' +
KITM_FX.map(([k]) => `[data-kitm-scope]:not([data-kitm]) .kitm-${k}{animation:kitm-${k}-h 0s 2.5s both}`).join('') +
'}';
function KitmStyle() {
// React hoists it once into the head, however many app blocks render
return (
<style href="kitm" precedence="default">
{KITM_CSS}
</style>
);
}
/** Entrance state of an area: undefined on the server, then wait (below the fold) or play */
function useEnter<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [state, setState] = useState<'wait' | 'play' | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el || prefersReducedMotion() || el.getBoundingClientRect().top < window.innerHeight * 0.92) return setState('play');
setState('wait');
const io = new IntersectionObserver(
([e]) => {
if (!e?.isIntersecting) return;
setState('play');
io.disconnect();
},
{ rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return [ref, { 'data-kitm-scope': '', 'data-kitm': state }] as const;
}
type Box = { x: number; y: number; w: number; h: number };
const boxOf = (el: Element, root: HTMLElement): Box => {
const r = el.getBoundingClientRect();
const b = root.getBoundingClientRect();
return { x: r.left - b.left + root.scrollLeft, y: r.top - b.top + root.scrollTop, w: r.width, h: r.height };
};
/**
* The marker ([data-glide]) is rendered inside the active item, so the server
* and a page without JavaScript show it in place. After a change it starts
* where the old one stood and glides into the new item.
*/
function useGlide<T extends HTMLElement>(key: unknown) {
const ref = useRef<T>(null);
const before = useRef<Box | null>(null);
// Read during render, while the DOM still shows the old marker
if (typeof window !== 'undefined' && ref.current) {
const m = ref.current.querySelector('[data-glide]');
before.current = m ? boxOf(m, ref.current) : null;
}
useIso(() => {
const root = ref.current;
const m = root?.querySelector<HTMLElement>('[data-glide]');
const a = before.current;
if (!root || !m || !a || prefersReducedMotion()) return;
const b = boxOf(m, root);
if (!b.w || !b.h || (Math.abs(a.x - b.x) < 1 && Math.abs(a.y - b.y) < 1 && Math.abs(a.w - b.w) < 1)) return;
m.animate([{ transformOrigin: '0 0', transform: `translate(${a.x - b.x}px, ${a.y - b.y}px) scale(${a.w / b.w}, ${a.h / b.h})` }, { transformOrigin: '0 0', transform: 'none' }], { duration: 420, easing: KITM_EASE });
}, [key]);
return ref;
}
/* ------------------------------------------------------------------ */
/* Diff */
/* ------------------------------------------------------------------ */
type Part = { kind: 'same' | 'add' | 'del'; text: string };
/** Longest common subsequence diff of two token lists. Small documents only, it is O(n·m). */
function diff(a: string[], b: string[]): { kind: Part['kind']; a?: number; b?: number }[] {
const n = a.length;
const m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
const out: { kind: Part['kind']; a?: number; b?: number }[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) out.push({ kind: 'same', a: i++, b: j++ });
else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ kind: 'del', a: i++ });
else out.push({ kind: 'add', b: j++ });
}
while (i < n) out.push({ kind: 'del', a: i++ });
while (j < m) out.push({ kind: 'add', b: j++ });
return out;
}
/** Word level diff of two texts, joined into runs */
function wordDiff(a: string, b: string): Part[] {
// Spaces and punctuation are tokens of their own, so a changed comma does not mark the whole word
const ta = a.split(/(\s+|[;:!?]|[.,](?=\s|$))/).filter(Boolean);
const tb = b.split(/(\s+|[;:!?]|[.,](?=\s|$))/).filter(Boolean);
const ops = diff(ta, tb);
const out: Part[] = [];
for (const o of ops) {
const text = o.kind === 'add' ? tb[o.b!] : ta[o.a!];
const last = out[out.length - 1];
if (last && last.kind === o.kind) last.text += text;
else out.push({ kind: o.kind, text });
}
return out;
}
const words = (s: string) => s.split(/\s+/).filter(Boolean).length;
/** Paragraph rows for a two-way view: same, changed (with a word diff), added, removed */
type Row = { kind: 'same' | 'change' | 'add' | 'del'; a?: string; b?: string; parts?: Part[] };
/** Share of words two paragraphs have in common, 0 to 1 */
function likeness(x: string, y: string) {
const split = (s: string) => s.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, '').split(/\s+/).filter(Boolean);
const wx = split(x);
const wy = split(y);
const set = new Set(wy);
const common = wx.filter((w) => set.has(w)).length;
// Dice coefficient: shared words against the length of both
return (2 * common) / Math.max(1, wx.length + wy.length);
}
function rows(a: string[], b: string[]): Row[] {
const ops = diff(a, b);
const out: Row[] = [];
for (let k = 0; k < ops.length; ) {
const o = ops[k];
if (o.kind === 'same') {
out.push({ kind: 'same', a: a[o.a!], b: b[o.b!] });
k++;
continue;
}
// A run of removed and added paragraphs: pair the ones that are clearly the same paragraph rewritten
const dels: number[] = [];
const adds: number[] = [];
while (k < ops.length && ops[k].kind !== 'same') {
if (ops[k].kind === 'del') dels.push(ops[k].a!);
else adds.push(ops[k].b!);
k++;
}
let ai = 0;
for (const d of dels) {
let best = -1;
let score = 0.34;
for (let j = ai; j < adds.length; j++) {
const sc = likeness(a[d], b[adds[j]]);
if (sc > score) {
score = sc;
best = j;
}
}
if (best < 0) {
out.push({ kind: 'del', a: a[d] });
continue;
}
for (; ai < best; ai++) out.push({ kind: 'add', b: b[adds[ai]] });
out.push({ kind: 'change', a: a[d], b: b[adds[best]], parts: wordDiff(a[d], b[adds[best]]) });
ai = best + 1;
}
for (; ai < adds.length; ai++) out.push({ kind: 'add', b: b[adds[ai]] });
}
return out;
}
function stats(a: string[], b: string[]) {
let add = 0;
let del = 0;
for (const r of rows(a, b)) {
if (r.kind === 'add') add += words(r.b!);
else if (r.kind === 'del') del += words(r.a!);
else if (r.kind === 'change') for (const p of r.parts!) p.kind === 'add' ? (add += words(p.text)) : p.kind === 'del' ? (del += words(p.text)) : 0;
}
return { add, del };
}
/* ------------------------------------------------------------------ */
/* Parts */
/* ------------------------------------------------------------------ */
const MIN = 60000;
const toMin = (iso: string) => Math.round(Date.parse(iso.length <= 10 ? iso + 'T00:00:00Z' : iso + (iso.endsWith('Z') ? '' : 'Z')) / MIN);
type P = Omit<VersionHistoryProps, 'variant' | 'sidebar' | 'labels' | 'className'> & { t: Texts; now: string; locale: Locale; versions: DocVersion[] };
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function run(onAction: VersionHistoryProps['onAction'], action: string, data: Record<string, unknown>) {
if (onAction) await onAction(action, data);
else await wait(prefersReducedMotion() ? 0 : 700);
}
function useWhen(locale: Locale, now: string, t: Texts) {
return useMemo(() => {
const tag = locale === 'de' ? 'de-DE' : 'en-US';
const time = new Intl.DateTimeFormat(tag, { hour: tag === 'de-DE' ? '2-digit' : 'numeric', minute: '2-digit', timeZone: 'UTC' });
const date = new Intl.DateTimeFormat(tag, { day: 'numeric', month: 'short', timeZone: 'UTC' });
const dayOf = (iso: string) => Math.floor(toMin(iso) / 1440);
const label = (iso: string) => {
const d = dayOf(now) - dayOf(iso);
return d === 0 ? t.today : d === 1 ? t.yesterday : date.format(new Date(toMin(iso) * MIN));
};
return {
day: label,
time: (iso: string) => time.format(new Date(toMin(iso) * MIN)),
full: (iso: string) => `${label(iso)}, ${time.format(new Date(toMin(iso) * MIN))}`,
dayKey: dayOf,
};
}, [locale, now, t]);
}
function Header({ title, subtitle, children }: { title: string; subtitle?: React.ReactNode; children?: React.ReactNode }) {
return (
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
{subtitle && <p className="text-[13px] text-muted-foreground">{subtitle}</p>}
<h1 className="mt-0.5 text-balance text-[clamp(1.5rem,1.2rem+1vw,1.875rem)] font-semibold tracking-[-0.035em] text-foreground">{title}</h1>
</div>
{children && <div className="flex flex-wrap items-center gap-2">{children}</div>}
</div>
);
}
function Main({ children, className }: { children: React.ReactNode; className?: string }) {
return <main className={cn('mx-auto w-full max-w-[80rem] flex-1 p-4 sm:p-6 lg:p-8', className)}>{children}</main>;
}
function Segmented<T extends string>({ options, value, onChange, label, className }: { options: { value: T; label: React.ReactNode }[]; value: T; onChange: (v: T) => void; label: string; className?: string }) {
const ref = useGlide<HTMLDivElement>(value);
const onKey = (e: React.KeyboardEvent, i: number) => {
const dir = e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 0;
if (!dir) return;
e.preventDefault();
const next = (i + dir + options.length) % options.length;
onChange(options[next].value);
ref.current?.querySelectorAll<HTMLButtonElement>('[role=radio]')[next]?.focus();
};
return (
<div ref={ref} role="radiogroup" aria-label={label} className={cn('relative inline-flex shrink-0 rounded-lg bg-muted p-1', className)}>
{options.map((o, i) => (
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
tabIndex={value === o.value ? 0 : -1}
onClick={() => onChange(o.value)}
onKeyDown={(e) => onKey(e, i)}
className={cn(
'relative inline-flex h-11 min-w-11 sm:h-10 sm:min-w-0 flex-1 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 text-[13px] font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)] sm:h-8',
value === o.value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{value === o.value && <span data-glide aria-hidden className="absolute inset-0 rounded-md bg-background shadow-[var(--shadow-sm)] ring-1 ring-border" />}
<span className="relative inline-flex items-center gap-1.5">{o.label}</span>
</button>
))}
</div>
);
}
function Live({ text }: { text: string }) {
return (
<p role="status" aria-live="polite" className="sr-only">
{text}
</p>
);
}
function Initials({ name, index = 0, className }: { name: string; index?: number; className?: string }) {
return (
<span aria-hidden className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[10.5px] font-semibold text-foreground', className)} style={{ background: `color-mix(in oklab, var(--primary) ${10 + (index % 4) * 7}%, var(--muted))` }}>
{name
.split(/\s+/)
.map((w) => w[0])
.slice(0, 2)
.join('')}
</span>
);
}
/** Inline marks for added and removed words */
function Marked({ parts }: { parts: Part[] }) {
return (
<>
{parts.map((p, i) =>
p.kind === 'same' ? (
<span key={i}>{p.text}</span>
) : p.kind === 'add' ? (
<ins key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--success)_20%,transparent)] px-0.5 text-foreground no-underline decoration-[color-mix(in_oklab,var(--success)_60%,transparent)] [box-decoration-break:clone]">
{p.text}
</ins>
) : (
<del key={i} className="rounded-[3px] bg-[color-mix(in_oklab,var(--destructive)_14%,transparent)] px-0.5 text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_60%,transparent)] [box-decoration-break:clone]">
{p.text}
</del>
),
)}
</>
);
}
/** The document drawn as a page, title first */
function Page({ children, className }: { children: React.ReactNode; className?: string }) {
return <article className={cn('rounded-xl border border-border bg-card px-5 py-6 shadow-[var(--highlight),var(--shadow-sm)] sm:px-10 sm:py-10', className)}>{children}</article>;
}
function Para({ i, children, className }: { i: number; children: React.ReactNode; className?: string }) {
return i === 0 ? <h2 className={cn('text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]', className)}>{children}</h2> : <p className={cn('mt-4 text-pretty text-[15px] leading-[1.75] text-foreground/90', className)}>{children}</p>;
}
/* ------------------------------------------------------------------ */
/* timeline */
/* ------------------------------------------------------------------ */
function Timeline({ t, title, subtitle, versions, defaultVersion, now, locale, onAction }: P) {
const id = useId();
const when = useWhen(locale, now, t);
const [list, setList] = useState(versions);
const [sel, setSel] = useState(defaultVersion ?? versions[1]?.id ?? versions[0]?.id ?? '');
const [named, setNamed] = useState(false);
const [marks, setMarks] = useState(true);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [open, setOpen] = useState(false);
const [scope, enter] = useEnter<HTMLDivElement>();
const listRef = useGlide<HTMLDivElement>(sel);
const shown = list.filter((v, i) => !named || v.name || i === 0);
const idx = list.findIndex((v) => v.id === sel);
const v = list[idx];
const prev = list[idx + 1];
const st = v && prev ? stats(prev.content, v.content) : { add: 0, del: 0 };
const days = useMemo(() => {
const m = new Map<number, DocVersion[]>();
for (const x of shown) m.set(when.dayKey(x.time), [...(m.get(when.dayKey(x.time)) ?? []), x]);
return [...m];
}, [shown, when]);
const restore = async () => {
if (!v) return;
setBusy(true);
await run(onAction, 'restore', { id: v.id });
const copy: DocVersion = { ...v, id: `${v.id}-r${Date.now()}`, time: now, name: undefined, auto: false };
setList((l) => [copy, ...l]);
setSel(copy.id);
setBusy(false);
setMsg(fill(t.restored, { when: when.full(v.time) }));
};
const pick = (x: string) => {
setSel(x);
setOpen(true);
setMsg('');
};
const doc = v && (
<div key={v.id} className="kitm-in">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-[12.5px] text-muted-foreground">{idx === 0 ? t.current : fill(t.previewOf, { when: when.full(v.time) })}</p>
<p className="mt-0.5 text-[13px] tabular-nums">{prev ? <span className="text-muted-foreground"><span className="font-medium text-success-foreground">+{fill(t.words, { n: st.add })}</span> · <span className="font-medium text-destructive">−{fill(t.words, { n: st.del })}</span></span> : <span className="text-muted-foreground">{t.noChanges}</span>}</p>
</div>
<div className="flex items-center gap-2">
<label className="flex min-h-11 cursor-pointer items-center gap-2 text-[13px] text-foreground sm:min-h-0">
<input type="checkbox" checked={marks} onChange={(e) => setMarks(e.target.checked)} className="size-4 accent-[var(--primary)]" />
{t.showChanges}
</label>
{idx > 0 && (
<Button size="sm" onClick={restore} disabled={busy} iconLeft={busy ? 'loader' : 'rotate-ccw'}>
{busy ? t.restoring : t.restore}
</Button>
)}
</div>
</div>
<Page>
{marks && prev
? rows(prev.content, v.content).map((r, i) =>
r.kind === 'same' ? (
<Para key={i} i={i}>
{r.b}
</Para>
) : r.kind === 'change' ? (
<Para key={i} i={i}>
<Marked parts={r.parts!} />
</Para>
) : r.kind === 'add' ? (
<Para key={i} i={i} className="-mx-2 rounded-md border-s-2 border-success bg-[color-mix(in_oklab,var(--success)_9%,transparent)] px-2">
{r.b}
</Para>
) : (
<Para key={i} i={i} className="-mx-2 rounded-md border-s-2 border-destructive bg-[color-mix(in_oklab,var(--destructive)_7%,transparent)] px-2 text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_50%,transparent)]">
{r.a}
</Para>
),
)
: v.content.map((c, i) => (
<Para key={i} i={i}>
{c}
</Para>
))}
</Page>
</div>
);
return (
<Main>
<Header title={title} subtitle={subtitle} />
{msg && (
<p role="status" className="kitm-in mt-5 flex items-center gap-3 rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_8%,var(--card))] px-4 py-3 text-[13.5px] text-foreground">
<Icon name="check-circle" size={17} className="shrink-0 text-success-foreground" />
<span className="min-w-0 flex-1">{msg}</span>
<button
type="button"
onClick={() => {
setList((l) => l.slice(1));
setSel(list[1]?.id ?? '');
setMsg('');
}}
className="h-11 shrink-0 rounded-md px-2 text-[13px] font-medium text-foreground hover:bg-accent sm:h-8"
>
{t.undo}
</button>
</p>
)}
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
{/* Desktop: the document with the version list on the right. Tablets and phones: the list first, a version opens as its own view. */}
<section aria-label={t.previewOf} className={cn('kitm-up min-w-0 lg:order-1', !open && 'max-lg:hidden')}>
<button type="button" onClick={() => setOpen(false)} className="-ms-1 mb-3 inline-flex h-11 items-center gap-1.5 rounded-md px-1 text-[13px] font-medium text-muted-foreground hover:text-foreground lg:hidden">
<Icon name="arrow-left" size={15} />
{t.versions}
</button>
{doc}
</section>
<aside aria-labelledby={`${id}-h`} className={cn('kitm-up [--kitm-d:100ms] min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:order-2 lg:sticky lg:top-6', open && 'max-lg:hidden')}>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3.5">
<h2 id={`${id}-h`} className="text-[14px] font-semibold text-foreground">
{t.versions}
</h2>
<label className="flex cursor-pointer items-center gap-2 text-[12.5px] text-muted-foreground">
<input type="checkbox" checked={named} onChange={(e) => setNamed(e.target.checked)} className="size-4 accent-[var(--primary)]" />
{t.onlyNamed}
</label>
</div>
<div ref={listRef} className="relative max-h-[38rem] overflow-y-auto overscroll-contain py-2">
{days.map(([d, vs]) => (
<div key={d} className="px-2 pb-2">
<p className="px-2 pb-1 pt-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">{when.day(vs[0].time)}</p>
<ul>
{vs.map((x) => {
const i = list.indexOf(x);
return (
<li key={x.id}>
<button type="button" aria-current={x.id === sel ? 'true' : undefined} onClick={() => pick(x.id)} className={cn('relative flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors', x.id === sel ? '' : 'hover:bg-[color-mix(in_oklab,var(--foreground)_4%,transparent)]', i === 0 && 'kitm-fly')}>
{x.id === sel && <span data-glide aria-hidden className="absolute inset-0 rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] ring-1 ring-[color-mix(in_oklab,var(--primary)_25%,transparent)]" />}
<Initials name={x.author} index={i} className="relative" />
<span className="relative min-w-0 flex-1">
<span className="flex items-center gap-1.5 text-[13.5px] font-medium text-foreground">
<span className="truncate">{x.name ?? when.time(x.time)}</span>
{i === 0 && <span className="shrink-0 rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[10.5px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.current}</span>}
</span>
<span className="block truncate text-[12px] text-muted-foreground">
{x.name ? `${when.time(x.time)} · ` : ''}
{x.auto ? t.auto : x.author}
</span>
</span>
{x.name && <Icon name="bookmark" size={14} className="relative shrink-0 text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]" />}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
</aside>
</div>
<Live text={msg} />
</Main>
);
}
/* ------------------------------------------------------------------ */
/* side-by-side */
/* ------------------------------------------------------------------ */
function VersionPick({ label, value, onChange, versions, when, t }: { label: string; value: string; onChange: (v: string) => void; versions: DocVersion[]; when: ReturnType<typeof useWhen>; t: Texts }) {
return (
<label className="grid min-w-0 gap-1.5 text-[12px] font-medium text-muted-foreground">
{label}
<span className="relative block">
<select value={value} onChange={(e) => onChange(e.target.value)} className="h-11 w-full min-w-0 cursor-pointer appearance-none truncate rounded-lg border border-input bg-background ps-3 pe-9 text-base font-normal text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-300 focus-visible:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:h-10 sm:text-[13.5px]">
{versions.map((v, i) => (
<option key={v.id} value={v.id}>
{i === 0 ? t.current : v.name ? `${v.name} · ${when.full(v.time)}` : `${when.full(v.time)} · ${v.author}`}
</option>
))}
</select>
<Icon name="chevrons-up-down" size={14} className="pointer-events-none absolute end-3 top-1/2 -translate-y-1/2 text-subtle-foreground" />
</span>
</label>
);
}
function SideBySide({ t, title, subtitle, versions, now, locale }: P) {
const when = useWhen(locale, now, t);
const [a, setA] = useState(versions[Math.min(3, versions.length - 1)]?.id ?? '');
const [b, setB] = useState(versions[0]?.id ?? '');
const [mode, setMode] = useState<'split' | 'unified'>('split');
const [openGaps, setOpenGaps] = useState<Set<number>>(new Set());
const [scope, enter] = useEnter<HTMLDivElement>();
const va = versions.find((x) => x.id === a);
const vb = versions.find((x) => x.id === b);
const rs = va && vb ? rows(va.content, vb.content) : [];
const st = va && vb ? stats(va.content, vb.content) : { add: 0, del: 0 };
// Runs of three or more unchanged paragraphs fold into one line
const blocks: ({ gap: number; from: number; rows: Row[] } | { row: Row; n: number })[] = [];
for (let i = 0; i < rs.length; ) {
if (rs[i].kind === 'same') {
let j = i;
while (j < rs.length && rs[j].kind === 'same') j++;
if (j - i >= 3 && i > 0) blocks.push({ gap: j - i, from: i, rows: rs.slice(i, j) });
else for (let k = i; k < j; k++) blocks.push({ row: rs[k], n: k });
i = j;
} else blocks.push({ row: rs[i], n: i++ });
}
const cell = (side: 'a' | 'b', r: Row) => {
const text = side === 'a' ? r.a : r.b;
if (text === undefined) return <span aria-hidden className="block h-full min-h-6 rounded bg-[repeating-linear-gradient(135deg,transparent_0_6px,color-mix(in_oklab,var(--foreground)_5%,transparent)_6px_7px)]" />;
if (r.kind === 'change') return <Marked parts={r.parts!.filter((p) => p.kind === 'same' || p.kind === (side === 'a' ? 'del' : 'add'))} />;
return text;
};
const tone = (side: 'a' | 'b', r: Row) => (r.kind === 'same' ? '' : side === 'a' ? (r.kind === 'add' ? '' : 'bg-[color-mix(in_oklab,var(--destructive)_6%,transparent)]') : r.kind === 'del' ? '' : 'bg-[color-mix(in_oklab,var(--success)_7%,transparent)]');
return (
<Main>
<Header title={title} subtitle={subtitle}>
<Segmented
label={t.view}
value={mode}
onChange={setMode}
className="max-md:hidden"
options={[
{ value: 'split', label: <><Icon name="columns" size={14} />{t.split}</> },
{ value: 'unified', label: <><Icon name="list" size={14} />{t.unified}</> },
]}
/>
</Header>
<div ref={scope} {...enter} className="mt-6 grid gap-4">
<div className="kitm-up grid grid-cols-[minmax(0,1fr)_auto] items-end gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<VersionPick label={t.before} value={a} onChange={setA} versions={versions} when={when} t={t} />
<button
type="button"
aria-label={t.swap}
onClick={() => {
setA(b);
setB(a);
}}
className="group col-start-2 row-span-2 row-start-1 grid size-11 place-items-center self-center rounded-lg border border-border bg-card text-muted-foreground shadow-[var(--shadow-sm)] hover:text-foreground sm:col-start-auto sm:row-span-1 sm:row-start-auto sm:size-10 sm:self-end"
>
<Icon name="arrows-left-right" size={16} className="transition-transform duration-500 ease-[var(--ease-kit)] group-active:rotate-180" />
</button>
<div className="col-start-1 sm:col-start-auto">
<VersionPick label={t.after} value={b} onChange={setB} versions={versions} when={when} t={t} />
</div>
</div>
<p key={`${a}-${b}`} className="kitm-in flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px] tabular-nums">
<span className="inline-flex items-center gap-1.5 font-medium text-success-foreground">
<span aria-hidden className="size-2 rounded-sm bg-success" />+{fill(t.words, { n: st.add })}
</span>
<span className="inline-flex items-center gap-1.5 font-medium text-destructive">
<span aria-hidden className="size-2 rounded-sm bg-destructive" />−{fill(t.words, { n: st.del })}
</span>
</p>
{/* Desktop and tablets: two columns or one. Phones: always one column. */}
<section key={`${a}-${b}-${mode}`} className="kitm-up [--kitm-d:80ms] overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className={cn('hidden border-b border-border bg-surface text-[12px] font-medium text-muted-foreground', mode === 'split' && 'md:grid md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,1fr)]')}>
<span />
<span className="border-s border-border px-4 py-2.5">{va ? when.full(va.time) : ''}</span>
<span className="border-s border-border px-4 py-2.5">{vb ? when.full(vb.time) : ''}</span>
</div>
<div className="divide-y divide-border text-[14px] leading-[1.7]">
{blocks.map((bl, k) =>
'gap' in bl ? (
openGaps.has(bl.from) ? (
bl.rows.map((r, j) => (
<div key={`g${bl.from}-${j}`} className={cn('kitm-in grid grid-cols-[3rem_minmax(0,1fr)] text-muted-foreground', mode === 'split' && 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,1fr)]')}>
<span className="py-2.5 pe-3 text-right text-[11.5px] tabular-nums text-subtle-foreground">{bl.from + j + 1}</span>
<p className={cn('border-s border-border px-4 py-2.5', mode === 'split' && 'md:hidden')}>{r.b}</p>
{mode === 'split' && <p className="hidden border-s border-border px-4 py-2.5 md:block">{r.a}</p>}
{mode === 'split' && <p className="hidden border-s border-border px-4 py-2.5 md:block">{r.b}</p>}
</div>
))
) : (
<button key={`g${k}`} type="button" onClick={() => setOpenGaps((s) => new Set(s).add(bl.from))} className="flex w-full items-center justify-center gap-2 bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--card))] py-2.5 text-[12.5px] font-medium text-muted-foreground hover:text-foreground">
<Icon name="arrows-up-down" size={14} />
{fill(t.expand, { n: bl.gap })}
</button>
)
) : (
<div key={k} className={cn('grid grid-cols-[3rem_minmax(0,1fr)]', mode === 'split' && 'md:grid-cols-[3rem_minmax(0,1fr)_minmax(0,1fr)]')}>
<span className="py-2.5 pe-3 text-right text-[11.5px] tabular-nums text-subtle-foreground">{bl.n + 1}</span>
{/* One column: removed, then added, changed with both marks */}
<div className={cn('border-s border-border', mode === 'split' && 'md:hidden')}>
{bl.row.kind === 'same' && <p className="px-4 py-2.5 text-foreground/90">{bl.row.b}</p>}
{bl.row.kind === 'change' && (
<p className="bg-[color-mix(in_oklab,var(--primary)_5%,transparent)] px-4 py-2.5">
<Marked parts={bl.row.parts!} />
</p>
)}
{bl.row.kind === 'del' && <p className="border-s-2 border-destructive bg-[color-mix(in_oklab,var(--destructive)_7%,transparent)] px-4 py-2.5 text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_50%,transparent)]">{bl.row.a}</p>}
{bl.row.kind === 'add' && <p className="border-s-2 border-success bg-[color-mix(in_oklab,var(--success)_8%,transparent)] px-4 py-2.5">{bl.row.b}</p>}
</div>
{mode === 'split' && (
<>
<p className={cn('hidden border-s border-border px-4 py-2.5 text-foreground/90 md:block', tone('a', bl.row))}>{cell('a', bl.row)}</p>
<p className={cn('hidden border-s border-border px-4 py-2.5 text-foreground/90 md:block', tone('b', bl.row))}>{cell('b', bl.row)}</p>
</>
)}
</div>
),
)}
</div>
</section>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* inline-diff */
/* ------------------------------------------------------------------ */
function InlineDiff({ t, title, subtitle, versions, defaultVersion, now, locale }: P) {
const when = useWhen(locale, now, t);
const newer = versions.find((v) => v.id === defaultVersion) ?? versions[0];
const older = versions[versions.indexOf(newer) + 1] ?? newer;
const rs = useMemo(() => rows(older.content, newer.content), [older, newer]);
const changes = rs.map((r, i) => ({ r, i })).filter((x) => x.r.kind !== 'same');
const [at, setAt] = useState(0);
const refs = useRef<(HTMLElement | null)[]>([]);
const [scope, enter] = useEnter<HTMLDivElement>();
const st = stats(older.content, newer.content);
const go = (n: number) => {
const k = (n + changes.length) % changes.length;
setAt(k);
const el = refs.current[changes[k]?.i];
el?.scrollIntoView({ block: 'center', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
el?.focus({ preventScroll: true });
};
const nav = (
<div className="flex items-center gap-1">
<button type="button" aria-label={t.prevChange} onClick={() => go(at - 1)} disabled={!changes.length} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9">
<Icon name="chevron-up" size={17} />
</button>
<span key={at} className="kitm-in min-w-[7.5rem] text-center text-[13px] tabular-nums text-foreground">
{changes.length ? fill(t.change, { n: at + 1, total: changes.length }) : t.noChanges}
</span>
<button type="button" aria-label={t.nextChange} onClick={() => go(at + 1)} disabled={!changes.length} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9">
<Icon name="chevron-down" size={17} />
</button>
</div>
);
return (
<Main className="pb-24 lg:pb-8">
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_17rem] lg:items-start">
<Page className="kitm-up">
{rs.map((r, i) => {
const k = changes.findIndex((c) => c.i === i);
const active = k === at;
const body = r.kind === 'same' ? r.b : r.kind === 'change' ? <Marked parts={r.parts!} /> : r.kind === 'add' ? r.b : r.a;
return (
<div
key={i}
ref={(el) => {
refs.current[i] = el;
}}
tabIndex={k >= 0 ? -1 : undefined}
className={cn('relative -mx-3 rounded-lg px-3 outline-none transition-[background-color,box-shadow] duration-500', k >= 0 && 'scroll-mt-24', active && 'bg-[color-mix(in_oklab,var(--primary)_6%,transparent)] shadow-[inset_2px_0_0_var(--primary)]')}
onClick={() => k >= 0 && setAt(k)}
>
<Para i={i} className={cn(i === 0 ? 'py-1' : 'mt-0 py-2', r.kind === 'del' && 'text-muted-foreground line-through decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)]', r.kind === 'add' && 'text-foreground')}>
{r.kind === 'add' && <span className="sr-only">{t.added}: </span>}
{r.kind === 'del' && <span className="sr-only">{t.removed}: </span>}
{r.kind === 'add' ? <ins className="bg-[color-mix(in_oklab,var(--success)_16%,transparent)] no-underline [box-decoration-break:clone]">{body}</ins> : body}
</Para>
</div>
);
})}
</Page>
{/* Desktop: a panel with the change count and the way through them. Tablets and phones: a bar at the bottom. */}
<aside className="kitm-up [--kitm-d:120ms] hidden rounded-xl border border-border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6 lg:block">
<p className="text-[12.5px] text-muted-foreground">
{when.full(older.time)} → {when.full(newer.time)}
</p>
<p className="mt-1 flex items-center gap-2 text-[13px] tabular-nums">
<span className="font-semibold text-success-foreground">+{st.add}</span>
<span className="font-semibold text-destructive">−{st.del}</span>
<span aria-hidden className="ms-1 flex h-2 flex-1 overflow-hidden rounded-full bg-track">
<span className="bg-success" style={{ flexGrow: st.add }} />
<span className="bg-destructive" style={{ flexGrow: st.del }} />
</span>
</p>
<div className="mt-4 border-t border-border pt-3">{nav}</div>
<ol className="mt-2 grid grid-cols-[minmax(0,1fr)] gap-0.5">
{changes.map((c, k) => (
<li key={c.i}>
<button type="button" onClick={() => go(k)} className={cn('flex w-full items-center gap-2.5 rounded-md px-2 py-2 text-left text-[12.5px] transition-colors', k === at ? 'bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground')}>
<span aria-hidden className={cn('size-2 shrink-0 rounded-sm', c.r.kind === 'add' ? 'bg-success' : c.r.kind === 'del' ? 'bg-destructive' : 'bg-primary')} />
<span className="min-w-0 flex-1 truncate">{(c.r.b ?? c.r.a ?? '').slice(0, 60)}</span>
</button>
</li>
))}
</ol>
</aside>
</div>
<div className="fixed inset-x-3 bottom-[calc(env(safe-area-inset-bottom)+12px)] z-30 mx-auto flex max-w-[26rem] items-center justify-between gap-2 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-1.5 ps-3.5 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl lg:hidden">
<span className="text-[12.5px] font-semibold tabular-nums">
<span className="text-success-foreground">+{st.add}</span> <span className="text-destructive">−{st.del}</span>
</span>
{nav}
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* named-versions */
/* ------------------------------------------------------------------ */
function NamedVersions({ t, title, subtitle, versions, now, locale, onAction }: P) {
const id = useId();
const when = useWhen(locale, now, t);
const [list, setList] = useState(versions);
const [picked, setPicked] = useState<string[]>([]);
const [naming, setNaming] = useState('');
const [draft, setDraft] = useState('');
const [compare, setCompare] = useState<[string, string] | null>(null);
const [scope, enter] = useEnter<HTMLDivElement>();
const toggle = (vid: string) => setPicked((p) => (p.includes(vid) ? p.filter((x) => x !== vid) : [...p.slice(-1), vid]));
const saveName = (vid: string) => {
const n = draft.trim();
if (!n) return setNaming('');
setList((l) => l.map((x) => (x.id === vid ? { ...x, name: n } : x)));
setNaming('');
void run(onAction, 'name', { id: vid, name: n });
};
const [ca, cb] = compare ? [list.find((x) => x.id === compare[0])!, list.find((x) => x.id === compare[1])!] : [];
const ordered = ca && cb && list.indexOf(ca) < list.indexOf(cb) ? [cb, ca] : [ca, cb];
return (
<Main className="pb-28">
<Header title={title} subtitle={subtitle} />
<p className="mt-2 max-w-[44rem] text-pretty text-[13.5px] text-muted-foreground">{t.nameHint}</p>
<div ref={scope} {...enter} className="mt-6">
<ol className="relative grid gap-3 ps-6 sm:ps-8">
<span aria-hidden className="absolute bottom-4 start-[7px] top-4 w-px bg-border sm:start-[11px]" />
{list.map((v, i) => {
const prev = list[i + 1];
const st = prev ? stats(prev.content, v.content) : null;
const on = picked.includes(v.id);
return (
<li key={v.id} className="kitm-up relative" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<span aria-hidden className={cn('absolute -start-6 top-5 grid size-[15px] place-items-center rounded-full border-2 border-background sm:-start-8 sm:size-[23px]', v.name ? 'bg-primary' : 'bg-border-strong')}>
{v.name && <Icon name="bookmark" size={11} className="hidden text-primary-foreground sm:block" />}
</span>
<div className={cn('flex items-start gap-3 rounded-xl border bg-card p-4 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow] duration-300 sm:items-center sm:gap-4 sm:p-5', on ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] shadow-[var(--highlight),0_0_0_3px_color-mix(in_oklab,var(--primary)_12%,transparent)]' : 'border-border')}>
<label className="-m-3.5 flex shrink-0 cursor-pointer items-center p-3.5 sm:m-0 sm:p-0" title={fill(t.pick, { name: v.name ?? when.full(v.time) })}>
<input type="checkbox" checked={on} onChange={() => toggle(v.id)} aria-label={fill(t.pick, { name: v.name ?? when.full(v.time) })} className="size-[18px] accent-[var(--primary)]" />
</label>
<div className="min-w-0 flex-1">
{naming === v.id ? (
<form
onSubmit={(e) => {
e.preventDefault();
saveName(v.id);
}}
className="flex flex-col gap-2 sm:flex-row"
>
<label htmlFor={`${id}-n`} className="sr-only">
{t.nameIt}
</label>
<input id={`${id}-n`} autoFocus value={draft} placeholder={t.namePlaceholder} onChange={(e) => setDraft(e.target.value)} onKeyDown={(e) => e.key === 'Escape' && setNaming('')} className="h-11 min-w-0 flex-1 rounded-lg border border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] bg-background px-3 text-base text-foreground shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] outline-none sm:h-9 sm:text-[14px]" />
<Button type="submit" size="sm">
{t.save}
</Button>
</form>
) : (
<p className="flex flex-wrap items-center gap-2">
<span className={cn('text-[15px] font-semibold', v.name ? 'text-foreground' : 'text-muted-foreground')}>{v.name ?? when.full(v.time)}</span>
{i === 0 && <span className="rounded bg-[color-mix(in_oklab,var(--primary)_12%,transparent)] px-1.5 py-px text-[11px] font-semibold text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">{t.current}</span>}
</p>
)}
<p className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12.5px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<Initials name={v.author} index={i} className="size-5 text-[8.5px]" />
{v.auto ? t.auto : v.author}
</span>
{v.name && <span>{when.full(v.time)}</span>}
{st && (
<span className="tabular-nums">
<span className="text-success-foreground">+{st.add}</span> <span className="text-destructive">−{st.del}</span>
</span>
)}
</p>
</div>
{naming !== v.id && (
<button
type="button"
aria-label={v.name ? fill(t.rename, { name: v.name }) : t.nameIt}
onClick={() => {
setDraft(v.name ?? '');
setNaming(v.id);
}}
className="-me-2 -mt-1 inline-flex h-11 min-w-11 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[13px] font-medium text-foreground transition-colors hover:bg-accent sm:m-0 sm:h-9"
>
<Icon name={v.name ? 'edit' : 'bookmark'} size={15} />
{!v.name && <span className="max-sm:sr-only">{t.nameIt}</span>}
</button>
)}
</div>
</li>
);
})}
</ol>
</div>
{/* The compare bar: grows with the picks */}
<div className="fixed inset-x-0 bottom-0 z-30 px-3 pb-[calc(env(safe-area-inset-bottom)+12px)] sm:px-6">
<div className="mx-auto flex max-w-[40rem] items-center gap-3 rounded-xl border border-border bg-[color-mix(in_oklab,var(--popover)_92%,transparent)] p-2.5 ps-4 shadow-[var(--highlight),var(--shadow-lg)] backdrop-blur-xl">
<span aria-hidden className="flex shrink-0 gap-1">
{[0, 1].map((k) => (
<span key={k} className={cn('size-2.5 rounded-full transition-colors duration-300', picked[k] ? 'bg-primary' : 'bg-border-strong')} />
))}
</span>
<p key={picked.length} className="kitm-in min-w-0 flex-1 truncate text-[13.5px] text-foreground">
{picked.length === 0 ? t.pickTwo : picked.length === 1 ? t.pickedOne : t.compareTwo}
</p>
<Button size="sm" disabled={picked.length < 2} onClick={() => setCompare([picked[0], picked[1]])} iconLeft="columns">
{t.compare}
</Button>
</div>
</div>
<Dialog open={Boolean(compare)} onOpenChange={(v) => !v && setCompare(null)} size="xl" title={ordered[0] && ordered[1] ? `${ordered[0].name ?? when.full(ordered[0].time)} → ${ordered[1].name ?? when.full(ordered[1].time)}` : t.compare}>
<div className="max-h-[min(65dvh,36rem)] overflow-y-auto pb-6">
{ordered[0] && ordered[1] && (
<div className="grid gap-3 text-[14.5px] leading-[1.75]">
{rows(ordered[0].content, ordered[1].content).map((r, i) => (
<p key={i} className={cn('rounded-md px-2', r.kind === 'add' && 'border-s-2 border-success bg-[color-mix(in_oklab,var(--success)_8%,transparent)]', r.kind === 'del' && 'border-s-2 border-destructive bg-[color-mix(in_oklab,var(--destructive)_7%,transparent)] text-muted-foreground line-through', i === 0 && 'text-[18px] font-semibold')}>
{r.kind === 'change' ? <Marked parts={r.parts!} /> : r.kind === 'del' ? r.a : r.b}
</p>
))}
</div>
)}
</div>
</Dialog>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* restore */
/* ------------------------------------------------------------------ */
function Restore({ t, title, subtitle, versions, defaultVersion, now, locale, onAction }: P) {
const id = useId();
const when = useWhen(locale, now, t);
const target = versions.find((v) => v.id === defaultVersion) ?? versions[versions.length - 1];
const current = versions[0];
const [how, setHow] = useState<'new' | 'copy'>('new');
const [state, setState] = useState<'idle' | 'busy' | 'done'>('idle');
const [scope, enter] = useEnter<HTMLDivElement>();
const rs = rows(current.content, target.content);
const changed = rs.filter((r) => r.kind !== 'same');
const st = stats(current.content, target.content);
const go = async () => {
setState('busy');
await run(onAction, how === 'new' ? 'restore' : 'restore-copy', { id: target.id });
setState('done');
};
return (
<Main>
<Header title={title} subtitle={subtitle} />
<div ref={scope} {...enter} className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-6 lg:grid-cols-[minmax(0,1fr)_24rem] lg:items-start">
<section className="kitm-up min-w-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3.5 sm:px-5">
<h2 className="text-[14px] font-semibold text-foreground">{t.whatChanges}</h2>
<p className="text-[12.5px] tabular-nums text-muted-foreground">
{fill(t.paragraphsChanged, { n: changed.length })} · <span className="text-success-foreground">+{st.add}</span> <span className="text-destructive">−{st.del}</span>
</p>
</div>
<ol className="divide-y divide-border">
{changed.map((r, i) => (
<li key={i} className="kitm-up grid gap-2 px-4 py-4 sm:px-5" style={{ '--kitm-d': `${i * 60}ms` } as React.CSSProperties}>
<p className="flex items-center gap-2 text-[11.5px] font-semibold uppercase tracking-[0.06em] text-subtle-foreground">
<span aria-hidden className={cn('size-2 rounded-sm', r.kind === 'add' ? 'bg-success' : r.kind === 'del' ? 'bg-destructive' : 'bg-primary')} />
{r.kind === 'add' ? t.added : r.kind === 'del' ? t.removed : t.changedLabel}
</p>
<p className="text-pretty text-[14px] leading-[1.7] text-foreground/90">{r.kind === 'change' ? <Marked parts={r.parts!} /> : r.kind === 'del' ? <del className="text-muted-foreground decoration-[color-mix(in_oklab,var(--destructive)_55%,transparent)]">{r.a}</del> : <ins className="bg-[color-mix(in_oklab,var(--success)_16%,transparent)] no-underline">{r.b}</ins>}</p>
</li>
))}
</ol>
</section>
<aside className="kitm-up [--kitm-d:120ms] rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] lg:sticky lg:top-6">
{state === 'done' ? (
<div className="kitm-in px-5 py-8 text-center">
<span className="mx-auto grid size-12 place-items-center rounded-full bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground">
<Icon name="check" size={22} strokeWidth={2.4} className="kitm-pop" />
</span>
<p className="mt-4 text-pretty text-[14px] text-foreground">{how === 'new' ? fill(t.restored, { when: when.full(target.time) }) : fill(t.copyMade, { name: target.content[0] ?? '' })}</p>
<Button variant="outline" size="sm" className="mt-5" onClick={() => setState('idle')}>
{t.undo}
</Button>
</div>
) : (
<>
<div className="border-b border-border p-4 sm:p-5">
<p className="flex items-center gap-3">
<Initials name={target.author} className="size-9 text-[11.5px]" />
<span className="min-w-0">
<span className="block truncate text-[14px] font-semibold text-foreground">{target.name ? fill(t.restoreNamed, { name: target.name }) : fill(t.restoreTitle, { when: when.full(target.time) })}</span>
<span className="block truncate text-[12.5px] text-muted-foreground">{fill(t.by, { name: target.author })}</span>
</span>
</p>
<p className="mt-3 text-pretty text-[13px] leading-snug text-muted-foreground">{t.restoreText}</p>
</div>
<fieldset className="grid gap-2 p-4 sm:p-5">
<legend className="sr-only">{t.restore}</legend>
{(['new', 'copy'] as const).map((k) => (
<label key={k} className={cn('relative flex cursor-pointer items-start gap-3 rounded-lg border p-3.5 transition-[border-color,background-color] duration-200', how === k ? 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]' : 'border-border hover:border-border-strong')}>
<input type="radio" name={`${id}-how`} checked={how === k} onChange={() => setHow(k)} className="peer sr-only" />
<span aria-hidden className={cn('mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-full border transition-colors peer-focus-visible:ring-2 peer-focus-visible:ring-[color-mix(in_oklab,var(--primary)_50%,transparent)]', how === k ? 'border-primary bg-primary' : 'border-border-strong')}>
<span className={cn('size-[7px] rounded-full bg-primary-foreground transition-transform duration-300', how === k ? 'scale-100' : 'scale-0')} />
</span>
<span className="min-w-0">
<span className="block text-[13.5px] font-medium text-foreground">{k === 'new' ? t.asNew : t.asCopy}</span>
<span className="mt-0.5 block text-pretty text-[12.5px] leading-snug text-muted-foreground">{k === 'new' ? t.asNewText : t.asCopyText}</span>
</span>
</label>
))}
<Button className="mt-2 w-full" onClick={go} disabled={state === 'busy'} iconLeft={state === 'busy' ? 'loader' : 'rotate-ccw'}>
{state === 'busy' ? t.restoring : how === 'new' ? t.asNew : t.asCopy}
</Button>
</fieldset>
</>
)}
</aside>
</div>
</Main>
);
}
/* ------------------------------------------------------------------ */
/* VersionHistory */
/* ------------------------------------------------------------------ */
/**
* Version history for documents and pages: a version list with a marked
* preview and restore, two versions side by side, changes inline with a way
* through them, named versions to compare and a careful restore.
* <VersionHistory variant="timeline" title="History" versions={[{ id, author, time, content }]} />
*/
export function VersionHistory({ variant = 'timeline', sidebar, labels, className, now = '2026-10-19T14:30', versions = [], ...props }: VersionHistoryProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const p: P = { ...props, t, now, locale, versions };
const body = (() => {
switch (variant) {
case 'side-by-side':
return <SideBySide {...p} />;
case 'inline-diff':
return <InlineDiff {...p} />;
case 'named-versions':
return <NamedVersions {...p} />;
case 'restore':
return <Restore {...p} />;
default:
return <Timeline {...p} />;
}
})();
if (!sidebar)
return (
<div className={cn('min-h-dvh bg-background', className)}>
<KitmStyle />
{body}
</div>
);
return (
<Sidebar mobile="drawer" {...sidebar} className={className}>
<KitmStyle />
{body}
</Sidebar>
);
}Häufige Fragen
Wie installiere ich Versionsverlauf?
Mit aiw add @ai-website/version-history. Der Befehl legt die Datei components/ui/version-history.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, dialog und sidebar. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Versionsverlauf?
5 Varianten, Versionsliste, Nebeneinander, Änderungen im Text, Benannte Versionen und Wiederherstellen. Du wählst sie mit der Prop variant, zum Beispiel variant="side-by-side".
Kann ich die Texte von Versionsverlauf übersetzen?
Ja. Versionsverlauf bringt 55 eigene Texte auf Deutsch und Englisch mit, etwa „Versionen“, „Aktuelle Version“ und „Benannt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Versionsverlauf einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Versionsverlauf mit aiw_components und installieren sie mit aiw_add @ai-website/version-history. Nenn im Prompt die Variante, etwa variant="timeline", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Versionsverlauf ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden VersionHistory-Code zum Kopieren.