mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
feat: add note version history and restore (#note-history)
Co-authored-by: fredicious <1580688+fredicious@users.noreply.github.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
fredicious
parent
57e34f5e66
commit
32b57eb0c6
@@ -5,6 +5,7 @@
|
||||
import TiptapEditor from './TiptapEditor.svelte';
|
||||
import ImageUpload from './ImageUpload.svelte';
|
||||
import ShareDialog from './ShareDialog.svelte';
|
||||
import NoteHistory from './NoteHistory.svelte';
|
||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||
import { notes } from '$lib/stores/notes.js';
|
||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||
@@ -19,6 +20,7 @@
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||
import Users from 'lucide-svelte/icons/users';
|
||||
import History from 'lucide-svelte/icons/history';
|
||||
|
||||
interface Props {
|
||||
note: Note | null;
|
||||
@@ -55,6 +57,7 @@
|
||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||
|
||||
let showShareDialog = $state(false);
|
||||
let showHistory = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||
const isOwner = $derived(note?.isOwner !== false);
|
||||
@@ -84,6 +87,37 @@
|
||||
showShareDialog = !showShareDialog;
|
||||
}
|
||||
|
||||
async function toggleHistory() {
|
||||
if (currentlyNew) {
|
||||
const id = await autoSave();
|
||||
if (!id) return;
|
||||
}
|
||||
showHistory = !showHistory;
|
||||
}
|
||||
|
||||
async function handleHistoryRestored() {
|
||||
if (!noteId) return;
|
||||
// Reload the note content from the server after a restore
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}`);
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
title = updated.title ?? '';
|
||||
content = updated.content ?? '';
|
||||
color = updated.color ?? 'default';
|
||||
checklistMode = updated.checklistMode ?? false;
|
||||
// Bump editor tick to force TiptapEditor to re-render
|
||||
editorTick++;
|
||||
// Update the store too
|
||||
notes.update((list) =>
|
||||
list.map((n) => (n.id === noteId ? { ...n, ...updated } : n))
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// failed silently
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||
$effect(() => {
|
||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||
@@ -381,6 +415,18 @@
|
||||
<Users class="h-5 w-5" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- History button (only for saved notes) -->
|
||||
{#if !currentlyNew}
|
||||
<button
|
||||
onclick={toggleHistory}
|
||||
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||
title="Version history"
|
||||
data-testid="history-toggle"
|
||||
>
|
||||
<History class="h-5 w-5 {showHistory ? 'text-[var(--primary)]' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -403,3 +449,11 @@
|
||||
onUpdate={handleCollaboratorsUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showHistory && noteId}
|
||||
<NoteHistory
|
||||
noteId={noteId}
|
||||
onClose={() => (showHistory = false)}
|
||||
onRestored={handleHistoryRestored}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import type { NoteVersionSummary, NoteVersion } from '$lib/types/index.js';
|
||||
import X from 'lucide-svelte/icons/x';
|
||||
import RotateCcw from 'lucide-svelte/icons/rotate-ccw';
|
||||
import Clock from 'lucide-svelte/icons/clock';
|
||||
import { showToast } from '$lib/stores/toast.js';
|
||||
|
||||
interface Props {
|
||||
noteId: string;
|
||||
onClose: () => void;
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
const { noteId, onClose, onRestored }: Props = $props();
|
||||
|
||||
let versions = $state<NoteVersionSummary[]>([]);
|
||||
let selectedVersion = $state<NoteVersion | null>(null);
|
||||
let isLoading = $state(true);
|
||||
let isLoadingVersion = $state(false);
|
||||
let isRestoring = $state(false);
|
||||
let showConfirm = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
void loadVersions();
|
||||
});
|
||||
|
||||
async function loadVersions() {
|
||||
isLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// failed silently
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
isLoadingVersion = true;
|
||||
selectedVersion = null;
|
||||
try {
|
||||
const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`);
|
||||
if (res.ok) {
|
||||
selectedVersion = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// failed silently
|
||||
} finally {
|
||||
isLoadingVersion = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreVersion() {
|
||||
if (!selectedVersion) return;
|
||||
isRestoring = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/notes/${noteId}/versions/${selectedVersion.id}/restore`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
if (res.ok) {
|
||||
showToast('Version restored successfully', 'success');
|
||||
onRestored();
|
||||
onClose();
|
||||
} else {
|
||||
showToast('Failed to restore version', 'error');
|
||||
}
|
||||
} catch {
|
||||
showToast('Failed to restore version', 'error');
|
||||
} finally {
|
||||
isRestoring = false;
|
||||
showConfirm = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10"
|
||||
onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
data-testid="history-overlay"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mx-4 flex w-full max-w-2xl flex-col overflow-hidden rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
data-testid="history-panel"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Clock class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
<h2 class="text-sm font-semibold text-[var(--text)]">Version History</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||
title="Close history"
|
||||
data-testid="close-history-btn"
|
||||
>
|
||||
<X class="h-4 w-4 text-[var(--text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<!-- Version list -->
|
||||
<div class="w-56 flex-none border-r border-[var(--border-subtle)] overflow-y-auto max-h-[60vh]">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if versions.length === 0}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
No history yet
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-[var(--border-subtle)]">
|
||||
{#each versions as v (v.id)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full px-3 py-3 text-left hover:bg-[var(--border)]/10 transition-colors {selectedVersion?.id === v.id ? 'bg-[var(--primary)]/10 border-l-2 border-[var(--primary)]' : ''}"
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="version-item"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<span class="text-xs font-semibold text-[var(--primary)]">v{v.version}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-[var(--text-muted)]">{formatDate(v.createdAt)}</div>
|
||||
{#if v.title}
|
||||
<div class="mt-1 truncate text-xs font-medium text-[var(--text)]">{v.title}</div>
|
||||
{/if}
|
||||
{#if v.contentPreview}
|
||||
<div class="mt-0.5 line-clamp-2 text-xs text-[var(--text-muted)]">{v.contentPreview}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Version preview -->
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
{#if isLoadingVersion}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
Loading…
|
||||
</div>
|
||||
{:else if selectedVersion}
|
||||
<div class="flex flex-1 flex-col overflow-y-auto max-h-[60vh]">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-3">
|
||||
{#if selectedVersion.title}
|
||||
<h3 class="mb-2 text-base font-semibold text-[var(--text)]">{selectedVersion.title}</h3>
|
||||
{/if}
|
||||
<pre class="whitespace-pre-wrap text-sm text-[var(--text)] font-[inherit]">{selectedVersion.content || '(empty)'}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Restore footer -->
|
||||
<div class="border-t border-[var(--border-subtle)] px-4 py-3">
|
||||
{#if showConfirm}
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
This will replace the current note. The current version will be saved as a snapshot.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={restoreVersion}
|
||||
disabled={isRestoring}
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--destructive)] bg-[var(--destructive)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50"
|
||||
data-testid="confirm-restore-btn"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
{isRestoring ? 'Restoring…' : 'Confirm Restore'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showConfirm = false)}
|
||||
class="rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:bg-[var(--border)]/10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showConfirm = true)}
|
||||
class="flex items-center gap-1.5 rounded-sm border border-[var(--border-subtle)] px-3 py-1.5 text-xs font-medium text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)] transition-colors"
|
||||
data-testid="restore-btn"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
Restore this version
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-center justify-center py-8 text-sm text-[var(--text-muted)]">
|
||||
Select a version to preview
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user