feat(frontend): add document history panel with diff and compare mode
Adds a collapsible history section to the document detail view, showing all saved versions with changed-field labels, word-level diff between adjacent versions, and a compare mode for any two arbitrary versions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -215,5 +215,23 @@
|
||||
"reset_password_submit": "Passwort speichern",
|
||||
"reset_password_mismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"reset_password_success": "Ihr Passwort wurde erfolgreich geändert. Sie können sich jetzt anmelden.",
|
||||
"login_forgot_password": "Passwort vergessen?"
|
||||
"login_forgot_password": "Passwort vergessen?",
|
||||
"history_section_title": "Verlauf",
|
||||
"history_loading": "Lade Verlauf…",
|
||||
"history_empty": "Noch keine Versionen vorhanden.",
|
||||
"history_version_label": "Version",
|
||||
"history_compare_mode": "Vergleichen",
|
||||
"history_compare_select_a": "Version A",
|
||||
"history_compare_select_b": "Version B",
|
||||
"history_compare_apply": "Vergleichen",
|
||||
"history_diff_no_changes": "Keine Änderungen zwischen diesen Versionen.",
|
||||
"history_field_title": "Titel",
|
||||
"history_field_document_date": "Datum",
|
||||
"history_field_location": "Ort",
|
||||
"history_field_document_location": "Archivstandort",
|
||||
"history_field_transcription": "Transkription",
|
||||
"history_field_summary": "Zusammenfassung",
|
||||
"history_field_sender": "Absender",
|
||||
"history_field_receivers": "Empfänger",
|
||||
"history_field_tags": "Schlagworte"
|
||||
}
|
||||
|
||||
@@ -215,5 +215,23 @@
|
||||
"reset_password_submit": "Save password",
|
||||
"reset_password_mismatch": "The passwords do not match.",
|
||||
"reset_password_success": "Your password has been changed successfully. You can now log in.",
|
||||
"login_forgot_password": "Forgot password?"
|
||||
"login_forgot_password": "Forgot password?",
|
||||
"history_section_title": "History",
|
||||
"history_loading": "Loading history…",
|
||||
"history_empty": "No versions yet.",
|
||||
"history_version_label": "Version",
|
||||
"history_compare_mode": "Compare",
|
||||
"history_compare_select_a": "Version A",
|
||||
"history_compare_select_b": "Version B",
|
||||
"history_compare_apply": "Compare",
|
||||
"history_diff_no_changes": "No changes between these versions.",
|
||||
"history_field_title": "Title",
|
||||
"history_field_document_date": "Date",
|
||||
"history_field_location": "Location",
|
||||
"history_field_document_location": "Archive location",
|
||||
"history_field_transcription": "Transcription",
|
||||
"history_field_summary": "Summary",
|
||||
"history_field_sender": "Sender",
|
||||
"history_field_receivers": "Receivers",
|
||||
"history_field_tags": "Tags"
|
||||
}
|
||||
|
||||
@@ -215,5 +215,23 @@
|
||||
"reset_password_submit": "Guardar contraseña",
|
||||
"reset_password_mismatch": "Las contraseñas no coinciden.",
|
||||
"reset_password_success": "Su contraseña ha sido cambiada con éxito. Ahora puede iniciar sesión.",
|
||||
"login_forgot_password": "¿Olvidó su contraseña?"
|
||||
"login_forgot_password": "¿Olvidó su contraseña?",
|
||||
"history_section_title": "Historial",
|
||||
"history_loading": "Cargando historial…",
|
||||
"history_empty": "Aún no hay versiones.",
|
||||
"history_version_label": "Versión",
|
||||
"history_compare_mode": "Comparar",
|
||||
"history_compare_select_a": "Versión A",
|
||||
"history_compare_select_b": "Versión B",
|
||||
"history_compare_apply": "Comparar",
|
||||
"history_diff_no_changes": "No hay cambios entre estas versiones.",
|
||||
"history_field_title": "Título",
|
||||
"history_field_document_date": "Fecha",
|
||||
"history_field_location": "Lugar",
|
||||
"history_field_document_location": "Ubicación en archivo",
|
||||
"history_field_transcription": "Transcripción",
|
||||
"history_field_summary": "Resumen",
|
||||
"history_field_sender": "Remitente",
|
||||
"history_field_receivers": "Destinatarios",
|
||||
"history_field_tags": "Etiquetas"
|
||||
}
|
||||
|
||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"diff": "^8.0.3",
|
||||
"openapi-fetch": "^0.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21,6 +22,7 @@
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"eslint": "^9.39.1",
|
||||
@@ -1895,6 +1897,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/diff": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz",
|
||||
"integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2780,6 +2789,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz",
|
||||
"integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.20.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"diff": "^8.0.3",
|
||||
"openapi-fetch": "^0.13.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -33,6 +34,7 @@
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/diff": "^7.0.2",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.10",
|
||||
"eslint": "^9.39.1",
|
||||
|
||||
@@ -308,6 +308,38 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getVersions"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/versions/{versionId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getVersion"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -516,6 +548,27 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
startedAt?: string;
|
||||
};
|
||||
DocumentVersionSummary: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: date-time */
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
DocumentVersion: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
/** Format: date-time */
|
||||
savedAt: string;
|
||||
/** Format: uuid */
|
||||
editorId?: string;
|
||||
editorName: string;
|
||||
snapshot: string;
|
||||
changedFields: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
@@ -1189,6 +1242,51 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getVersions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentVersionSummary"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getVersion: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
versionId: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DocumentVersion"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
import { diffWords } from 'diff';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -38,6 +39,237 @@ async function loadFile(id: string) {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── History panel ────────────────────────────────────────────────────────────
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
|
||||
type SnapshotDoc = {
|
||||
title?: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type DiffEntry =
|
||||
| {
|
||||
kind: 'text';
|
||||
field: string;
|
||||
label: string;
|
||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||
}
|
||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||
|
||||
let historyOpen = $state(false);
|
||||
let historyLoaded = $state(false);
|
||||
let historyLoading = $state(false);
|
||||
let versions = $state<VersionSummary[]>([]);
|
||||
|
||||
let compareMode = $state(false);
|
||||
let compareA = $state('');
|
||||
let compareB = $state('');
|
||||
|
||||
let selectedVersionId = $state<string | null>(null);
|
||||
let diffEntries = $state<DiffEntry[]>([]);
|
||||
let diffLoading = $state(false);
|
||||
let noDiff = $state(false);
|
||||
|
||||
const fieldLabels: Record<string, () => string> = {
|
||||
title: m.history_field_title,
|
||||
documentDate: m.history_field_document_date,
|
||||
location: m.history_field_location,
|
||||
documentLocation: m.history_field_document_location,
|
||||
transcription: m.history_field_transcription,
|
||||
summary: m.history_field_summary,
|
||||
sender: m.history_field_sender,
|
||||
receivers: m.history_field_receivers,
|
||||
tags: m.history_field_tags
|
||||
};
|
||||
|
||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||
|
||||
function parseSnapshot(raw: string): SnapshotDoc {
|
||||
try {
|
||||
return JSON.parse(raw) as SnapshotDoc;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
return `${p.firstName} ${p.lastName}`.trim();
|
||||
}
|
||||
|
||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||
const entries: DiffEntry[] = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
const parts = diffWords(a, b);
|
||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||
}
|
||||
|
||||
for (const field of SCALAR_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
||||
}
|
||||
|
||||
// sender
|
||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||
if (senderA !== senderB) {
|
||||
const removed = senderA ? [senderA] : [];
|
||||
const added = senderB ? [senderB] : [];
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'sender',
|
||||
label: fieldLabels['sender'](),
|
||||
removed,
|
||||
added
|
||||
});
|
||||
}
|
||||
|
||||
// receivers
|
||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'receivers',
|
||||
label: fieldLabels['receivers'](),
|
||||
removed: removedReceivers,
|
||||
added: addedReceivers
|
||||
});
|
||||
}
|
||||
|
||||
// tags
|
||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'tags',
|
||||
label: fieldLabels['tags'](),
|
||||
removed: removedTags,
|
||||
added: addedTags
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||
const res = await fetch(`/api/documents/${doc.id}/versions/${versionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
const v = await res.json();
|
||||
return parseSnapshot(v.snapshot);
|
||||
}
|
||||
|
||||
async function toggleHistory() {
|
||||
historyOpen = !historyOpen;
|
||||
if (historyOpen && !historyLoaded) {
|
||||
historyLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
if (selectedVersionId === versionId) {
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
return;
|
||||
}
|
||||
selectedVersionId = versionId;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === versionId);
|
||||
const newerSnap = await fetchSnapshot(versionId);
|
||||
const olderSnap = idx + 1 < versions.length ? await fetchSnapshot(versions[idx + 1].id) : null;
|
||||
const entries = buildDiff(olderSnap, newerSnap);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompare() {
|
||||
if (!compareA || !compareB || compareA === compareB) return;
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||
// compareA is the "older" baseline, compareB is "newer"
|
||||
const entries = buildDiff(snapA, snapB);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function versionLabel(v: VersionSummary, index: number): string {
|
||||
return `Version ${versions.length - index} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen flex-col bg-white">
|
||||
@@ -346,6 +578,212 @@ async function loadFile(id: string) {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 4. HISTORY GROUP -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between border-b border-brand-sand pb-2">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||
{m.history_section_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
class="flex items-center gap-1 rounded p-1 text-gray-400 transition hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||
aria-expanded={historyOpen}
|
||||
aria-label={m.history_section_title()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform duration-200 {historyOpen ? 'rotate-180' : ''}"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if historyOpen}
|
||||
<div class="mt-4 space-y-3">
|
||||
{#if historyLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if versions.length === 0}
|
||||
<p class="font-serif text-sm text-gray-400 italic">{m.history_empty()}</p>
|
||||
{:else}
|
||||
<!-- Compare mode toggle -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
compareMode = !compareMode;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
selectedVersionId = null;
|
||||
}}
|
||||
class="font-sans text-xs font-medium transition {compareMode
|
||||
? 'text-brand-navy underline'
|
||||
: 'text-gray-400 hover:text-brand-navy'}"
|
||||
>
|
||||
{m.history_compare_mode()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if compareMode}
|
||||
<!-- Compare selects -->
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label
|
||||
for="compare-a"
|
||||
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||
>{m.history_compare_select_a()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="compare-b"
|
||||
class="mb-1 block font-sans text-[10px] text-gray-400 uppercase"
|
||||
>{m.history_compare_select_b()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-brand-sand bg-white px-2 py-1 font-sans text-xs text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={applyCompare}
|
||||
disabled={!compareA || !compareB || compareA === compareB}
|
||||
class="w-full rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white transition hover:bg-brand-navy/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Version list -->
|
||||
<ul class="divide-y divide-brand-sand">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectVersion(v.id)}
|
||||
class="w-full py-2 text-left transition hover:bg-brand-sand/30 {selectedVersionId ===
|
||||
v.id
|
||||
? 'border-l-2 border-brand-mint pl-2'
|
||||
: 'pl-0'}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||
Version {versions.length - i}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-gray-400">
|
||||
{formatDateTime(v.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-sans text-[11px] text-gray-500">{v.editorName}</span>
|
||||
{#if v.changedFields && v.changedFields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each v.changedFields as field (field)}
|
||||
<span
|
||||
class="rounded bg-brand-sand/50 px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-gray-500 uppercase"
|
||||
>
|
||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<!-- Diff panel -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||
>
|
||||
{m.history_diff_no_changes()}
|
||||
</div>
|
||||
{:else if diffEntries.length > 0}
|
||||
<div class="space-y-4 rounded-sm border border-brand-sand bg-white p-4">
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||
>{entry.label}</span
|
||||
>
|
||||
{#if entry.kind === 'text'}
|
||||
<p class="font-serif text-sm leading-relaxed">
|
||||
{#each entry.parts as part, partIdx (partIdx)}
|
||||
{#if part.added}
|
||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||
{:else if part.removed}
|
||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span
|
||||
>
|
||||
{:else}
|
||||
<span>{part.value}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</p>
|
||||
{:else if entry.kind === 'scalar'}
|
||||
<div class="flex items-center gap-2 font-serif text-sm">
|
||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||
<svg
|
||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||
</div>
|
||||
{:else if entry.kind === 'relation'}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each entry.removed as item (item)}
|
||||
<span
|
||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
{#each entry.added as item (item)}
|
||||
<span
|
||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||
>{item}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
||||
<p class="truncate">ID: {doc.id}</p>
|
||||
|
||||
@@ -452,6 +452,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz"
|
||||
integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==
|
||||
|
||||
"@types/diff@^7.0.2":
|
||||
version "7.0.2"
|
||||
resolved "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz"
|
||||
integrity sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==
|
||||
|
||||
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.5", "@types/estree@^1.0.6", "@types/estree@1.0.8":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
|
||||
@@ -888,6 +893,11 @@ devalue@^5.6.4:
|
||||
resolved "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz"
|
||||
integrity sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==
|
||||
|
||||
diff@^8.0.3:
|
||||
version "8.0.3"
|
||||
resolved "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz"
|
||||
integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==
|
||||
|
||||
enhanced-resolve@^5.19.0:
|
||||
version "5.20.0"
|
||||
resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz"
|
||||
|
||||
Reference in New Issue
Block a user