split: history-ui (new Svelte workspace)
New web UI for browsing vault history. Svelte + Vite app with components for activity feed, dashboard, document detail, diff view, file tree, header, login, time slider, toast container, vault picker; lib/api.ts and stores.svelte.ts for client state. (Generated TS types were added in PR6.)
This commit is contained in:
parent
42c9d55489
commit
5a070340f1
22 changed files with 3660 additions and 0 deletions
13
frontend/history-ui/index.html
Normal file
13
frontend/history-ui/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>VaultLink2</title>
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
frontend/history-ui/package.json
Normal file
16
frontend/history-ui/package.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"name": "history-ui",
|
||||||
|
"version": "0.14.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"test": "echo 'no tests yet'"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
frontend/history-ui/src/App.svelte
Normal file
78
frontend/history-ui/src/App.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth, nav, toasts } from "./lib/stores.svelte";
|
||||||
|
import { listVaults } from "./lib/api";
|
||||||
|
import Login from "./components/Login.svelte";
|
||||||
|
import VaultPicker from "./components/VaultPicker.svelte";
|
||||||
|
import Dashboard from "./components/Dashboard.svelte";
|
||||||
|
import ToastContainer from "./components/ToastContainer.svelte";
|
||||||
|
|
||||||
|
let restoring = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const saved = auth.tryRestore();
|
||||||
|
if (!saved) {
|
||||||
|
restoring = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listVaults(saved.token)
|
||||||
|
.then((response) => {
|
||||||
|
auth.authenticate(
|
||||||
|
saved.token,
|
||||||
|
response.userName,
|
||||||
|
response.vaults
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
saved.vaultId &&
|
||||||
|
response.vaults.some(
|
||||||
|
(v) => v.name === saved.vaultId
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
auth.selectVault(saved.vaultId);
|
||||||
|
}
|
||||||
|
restoring = false;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
restoring = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if restoring}
|
||||||
|
<div class="loading-screen">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
{:else if !auth.token}
|
||||||
|
<Login />
|
||||||
|
{:else if !auth.isAuthenticated}
|
||||||
|
<VaultPicker />
|
||||||
|
{:else}
|
||||||
|
<Dashboard
|
||||||
|
selectedDocumentId={nav.current.kind === "document" ? nav.current.documentId : undefined}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ToastContainer />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.loading-screen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--bg-tertiary);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
101
frontend/history-ui/src/app.css
Normal file
101
frontend/history-ui/src/app.css
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-secondary: #161b22;
|
||||||
|
--bg-tertiary: #21262d;
|
||||||
|
--bg-hover: #30363d;
|
||||||
|
--border: #30363d;
|
||||||
|
--border-light: #21262d;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--text-muted: #8b949e;
|
||||||
|
--text-subtle: #6e7681;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent-hover: #79c0ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--green-bg: rgba(63, 185, 80, 0.15);
|
||||||
|
--red: #f85149;
|
||||||
|
--red-bg: rgba(248, 81, 73, 0.15);
|
||||||
|
--orange: #d29922;
|
||||||
|
--orange-bg: rgba(210, 153, 34, 0.15);
|
||||||
|
--purple: #bc8cff;
|
||||||
|
--purple-bg: rgba(188, 140, 255, 0.15);
|
||||||
|
--blue: #58a6ff;
|
||||||
|
--blue-bg: rgba(88, 166, 255, 0.15);
|
||||||
|
--mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body, #app {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
346
frontend/history-ui/src/components/ActivityFeed.svelte
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { VersionEvent } from "../lib/view-types";
|
||||||
|
import {
|
||||||
|
absoluteTime,
|
||||||
|
formatBytes
|
||||||
|
} from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
versions: VersionEvent[];
|
||||||
|
loading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
onSelectDocument: (documentId: string) => void;
|
||||||
|
onTimeTravel: (vaultUpdateId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
versions,
|
||||||
|
loading,
|
||||||
|
hasMore,
|
||||||
|
onLoadMore,
|
||||||
|
onSelectDocument,
|
||||||
|
onTimeTravel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function timeOfDay(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by day
|
||||||
|
let grouped = $derived.by(() => {
|
||||||
|
const groups: { date: string; items: VersionEvent[] }[] = [];
|
||||||
|
const sortedDesc = [...versions].sort(
|
||||||
|
(a, b) => b.vaultUpdateId - a.vaultUpdateId
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const v of sortedDesc) {
|
||||||
|
const date = new Date(v.updatedDate).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{ month: "long", day: "numeric", year: "numeric" }
|
||||||
|
);
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last && last.date === date) {
|
||||||
|
last.items.push(v);
|
||||||
|
} else {
|
||||||
|
groups.push({ date, items: [v] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
created: "var(--green)",
|
||||||
|
updated: "var(--blue)",
|
||||||
|
renamed: "var(--orange)",
|
||||||
|
deleted: "var(--red)",
|
||||||
|
restored: "var(--purple)"
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionBgColors: Record<string, string> = {
|
||||||
|
created: "var(--green-bg)",
|
||||||
|
updated: "var(--blue-bg)",
|
||||||
|
renamed: "var(--orange-bg)",
|
||||||
|
deleted: "var(--red-bg)",
|
||||||
|
restored: "var(--purple-bg)"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="feed">
|
||||||
|
{#if loading && versions.length === 0}
|
||||||
|
<div class="feed-loading">Loading activity...</div>
|
||||||
|
{:else if versions.length === 0}
|
||||||
|
<div class="feed-empty">
|
||||||
|
No activity yet. Documents will appear here as sync clients
|
||||||
|
make changes.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each grouped as group}
|
||||||
|
<div class="day-group">
|
||||||
|
<div class="day-header">{group.date}</div>
|
||||||
|
<div class="items-list">
|
||||||
|
{#each group.items as event}
|
||||||
|
<div class="feed-item">
|
||||||
|
<button
|
||||||
|
class="feed-item-main"
|
||||||
|
onclick={() =>
|
||||||
|
onSelectDocument(event.documentId)}
|
||||||
|
>
|
||||||
|
<div class="feed-timeline">
|
||||||
|
<div
|
||||||
|
class="timeline-dot"
|
||||||
|
style="background: {actionColors[
|
||||||
|
event.action
|
||||||
|
]}"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="feed-content">
|
||||||
|
<div class="feed-header">
|
||||||
|
<span
|
||||||
|
class="action-pill"
|
||||||
|
style="color: {actionColors[
|
||||||
|
event.action
|
||||||
|
]}; background: {actionBgColors[
|
||||||
|
event.action
|
||||||
|
]}"
|
||||||
|
>
|
||||||
|
{event.action}
|
||||||
|
</span>
|
||||||
|
<span class="feed-path">
|
||||||
|
{#if event.action === "renamed" && event.previousPath}
|
||||||
|
<span class="prev-path"
|
||||||
|
>{event.previousPath}</span
|
||||||
|
>
|
||||||
|
<span class="arrow"
|
||||||
|
>→</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class:deleted={event.action ===
|
||||||
|
"deleted"}
|
||||||
|
>
|
||||||
|
{event.relativePath}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="feed-meta">
|
||||||
|
<span class="feed-user"
|
||||||
|
>{event.userId}</span
|
||||||
|
>
|
||||||
|
<span class="feed-dot"
|
||||||
|
>·</span
|
||||||
|
>
|
||||||
|
<span class="feed-size"
|
||||||
|
>{formatBytes(
|
||||||
|
event.contentSize
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="feed-time-btn"
|
||||||
|
title="Time travel to {absoluteTime(event.updatedDate)}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTimeTravel(event.vaultUpdateId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{timeOfDay(event.updatedDate)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if hasMore}
|
||||||
|
<div class="load-more">
|
||||||
|
<button class="load-more-btn" onclick={onLoadMore}>
|
||||||
|
Load older activity
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.feed {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-loading,
|
||||||
|
.feed-empty {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-item-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 0 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-list::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 21px;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-top: 6px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-pill {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev-path {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-dot {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-time-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-left: 1px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-time-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-left-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
167
frontend/history-ui/src/components/ConfirmDialog.svelte
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
destructive?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel,
|
||||||
|
destructive = false,
|
||||||
|
loading = false,
|
||||||
|
onConfirm,
|
||||||
|
onCancel
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div class="backdrop" onclick={onCancel} role="presentation">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<div
|
||||||
|
class="dialog"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<h3 class="dialog-title">{title}</h3>
|
||||||
|
<p class="dialog-message">{message}</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button class="btn-cancel" onclick={onCancel} disabled={loading}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-confirm"
|
||||||
|
class:destructive
|
||||||
|
onclick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<span class="btn-spinner"></span>
|
||||||
|
{/if}
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 480px;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: scale-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover:not(:disabled) {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.destructive {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm.destructive:hover:not(:disabled) {
|
||||||
|
background: #f97583;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm:disabled,
|
||||||
|
.btn-cancel:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
|
to { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
508
frontend/history-ui/src/components/Dashboard.svelte
Normal file
508
frontend/history-ui/src/components/Dashboard.svelte
Normal file
|
|
@ -0,0 +1,508 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
nav,
|
||||||
|
toasts,
|
||||||
|
buildTree,
|
||||||
|
enrichVersions,
|
||||||
|
relativeTime,
|
||||||
|
formatBytes,
|
||||||
|
type View
|
||||||
|
} from "../lib/stores.svelte";
|
||||||
|
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
||||||
|
import type { VaultHistoryResponse } from "../lib/types/VaultHistoryResponse";
|
||||||
|
import type { VersionEvent, TreeNode } from "../lib/view-types";
|
||||||
|
import FileTree from "./FileTree.svelte";
|
||||||
|
import ActivityFeed from "./ActivityFeed.svelte";
|
||||||
|
import DocumentDetail from "./DocumentDetail.svelte";
|
||||||
|
import TimeSlider from "./TimeSlider.svelte";
|
||||||
|
import Header from "./Header.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedDocumentId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selectedDocumentId }: Props = $props();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
let latestDocuments = $state<DocumentVersionWithoutContent[]>([]);
|
||||||
|
let historyVersions = $state<DocumentVersionWithoutContent[]>([]);
|
||||||
|
let historyHasMore = $state(false);
|
||||||
|
let loadingDocs = $state(true);
|
||||||
|
let loadingHistory = $state(true);
|
||||||
|
let showDeleted = $state(false);
|
||||||
|
let searchQuery = $state("");
|
||||||
|
let activeTab = $state<"activity" | "files">("activity");
|
||||||
|
|
||||||
|
// Time travel
|
||||||
|
let maxUpdateId = $state(0);
|
||||||
|
let minUpdateId = $state(0);
|
||||||
|
let timeSliderValue = $state<number | null>(null);
|
||||||
|
|
||||||
|
// Derived
|
||||||
|
let tree = $derived(buildTree(latestDocuments, showDeleted));
|
||||||
|
let enrichedHistory = $derived(enrichVersions(historyVersions));
|
||||||
|
let stats = $derived({
|
||||||
|
totalDocs: latestDocuments.filter((d) => !d.isDeleted).length,
|
||||||
|
deletedDocs: latestDocuments.filter((d) => d.isDeleted).length,
|
||||||
|
totalSize: latestDocuments
|
||||||
|
.filter((d) => !d.isDeleted)
|
||||||
|
.reduce((sum, d) => sum + d.contentSize, 0),
|
||||||
|
users: [...new Set(latestDocuments.map((d) => d.userId))]
|
||||||
|
});
|
||||||
|
|
||||||
|
let filteredTree = $derived.by(() => {
|
||||||
|
if (!searchQuery) return tree;
|
||||||
|
return filterTree(tree, searchQuery.toLowerCase());
|
||||||
|
});
|
||||||
|
|
||||||
|
function filterTree(node: TreeNode, query: string): TreeNode {
|
||||||
|
if (!node.isFolder) {
|
||||||
|
return node.name.toLowerCase().includes(query) ? node : { ...node, children: [] };
|
||||||
|
}
|
||||||
|
const filteredChildren = node.children
|
||||||
|
.map((c) => filterTree(c, query))
|
||||||
|
.filter((c) => c.isFolder ? c.children.length > 0 : true)
|
||||||
|
.filter((c) => !c.isFolder || c.children.length > 0);
|
||||||
|
return { ...node, children: filteredChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time travel: compute vault state at a given updateId
|
||||||
|
let timeFilteredDocs = $derived.by(() => {
|
||||||
|
if (timeSliderValue === null || timeSliderValue >= maxUpdateId) {
|
||||||
|
return latestDocuments;
|
||||||
|
}
|
||||||
|
// From all history, find the latest version per documentId at or before timeSliderValue
|
||||||
|
const byDoc = new Map<string, DocumentVersionWithoutContent>();
|
||||||
|
for (const v of historyVersions) {
|
||||||
|
if (v.vaultUpdateId <= timeSliderValue) {
|
||||||
|
const existing = byDoc.get(v.documentId);
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
v.vaultUpdateId > existing.vaultUpdateId
|
||||||
|
) {
|
||||||
|
byDoc.set(v.documentId, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byDoc.values()];
|
||||||
|
});
|
||||||
|
|
||||||
|
let timeFilteredTree = $derived(
|
||||||
|
buildTree(
|
||||||
|
timeSliderValue !== null && timeSliderValue < maxUpdateId
|
||||||
|
? timeFilteredDocs
|
||||||
|
: latestDocuments,
|
||||||
|
showDeleted
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
let displayTree = $derived(
|
||||||
|
searchQuery ? filteredTree : timeFilteredTree
|
||||||
|
);
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
async function loadData() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
loadingDocs = true;
|
||||||
|
loadingHistory = true;
|
||||||
|
|
||||||
|
api.ping().then((ping) => {
|
||||||
|
auth.serverVersion = ping.serverVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.fetchLatestDocuments();
|
||||||
|
latestDocuments = response.latestDocuments;
|
||||||
|
maxUpdateId = Number(response.lastUpdateId);
|
||||||
|
} catch (e) {
|
||||||
|
toasts.add("Failed to load documents", "error");
|
||||||
|
} finally {
|
||||||
|
loadingDocs = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.fetchVaultHistory(500);
|
||||||
|
historyVersions = response.versions;
|
||||||
|
historyHasMore = response.hasMore;
|
||||||
|
if (historyVersions.length > 0) {
|
||||||
|
minUpdateId = Math.min(
|
||||||
|
...historyVersions.map((v) => v.vaultUpdateId)
|
||||||
|
);
|
||||||
|
maxUpdateId = Math.max(
|
||||||
|
maxUpdateId,
|
||||||
|
Math.max(
|
||||||
|
...historyVersions.map((v) => v.vaultUpdateId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toasts.add("Failed to load history", "error");
|
||||||
|
} finally {
|
||||||
|
loadingHistory = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreHistory() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api || !historyHasMore) return;
|
||||||
|
|
||||||
|
const oldest = Math.min(
|
||||||
|
...historyVersions.map((v) => v.vaultUpdateId)
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const response = await api.fetchVaultHistory(500, oldest);
|
||||||
|
historyVersions = [...historyVersions, ...response.versions];
|
||||||
|
historyHasMore = response.hasMore;
|
||||||
|
minUpdateId = Math.min(
|
||||||
|
minUpdateId,
|
||||||
|
...response.versions.map((v) => v.vaultUpdateId)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load more history", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDocument(documentId: string) {
|
||||||
|
nav.goto({ kind: "document", documentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (auth.isAuthenticated) {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dashboard">
|
||||||
|
<Header
|
||||||
|
vaultId={auth.vaultId}
|
||||||
|
serverVersion={auth.serverVersion}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="main-layout">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sidebar">
|
||||||
|
{#if !loadingDocs}
|
||||||
|
<div class="sidebar-stats">
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{stats.totalDocs}</span>
|
||||||
|
<span class="stat-label">files</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value"
|
||||||
|
>{formatBytes(stats.totalSize)}</span
|
||||||
|
>
|
||||||
|
<span class="stat-label">total</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{stats.users.length}</span>
|
||||||
|
<span class="stat-label"
|
||||||
|
>user{stats.users.length !== 1 ? "s" : ""}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="sidebar-search">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Filter files..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-controls">
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={showDeleted}
|
||||||
|
/>
|
||||||
|
Show deleted
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-tree">
|
||||||
|
{#if loadingDocs}
|
||||||
|
<div class="loading-placeholder">Loading...</div>
|
||||||
|
{:else}
|
||||||
|
<FileTree
|
||||||
|
node={displayTree}
|
||||||
|
selectedId={selectedDocumentId ?? null}
|
||||||
|
onSelect={selectDocument}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="content">
|
||||||
|
{#if maxUpdateId > 0}
|
||||||
|
<div class="time-slider-container">
|
||||||
|
<TimeSlider
|
||||||
|
min={minUpdateId}
|
||||||
|
max={maxUpdateId}
|
||||||
|
value={timeSliderValue}
|
||||||
|
versions={historyVersions}
|
||||||
|
onchange={(v) => {
|
||||||
|
timeSliderValue = v;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedDocumentId}
|
||||||
|
<DocumentDetail
|
||||||
|
documentId={selectedDocumentId}
|
||||||
|
onClose={() => nav.goHome()}
|
||||||
|
onRestore={handleRefresh}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="tabs">
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === "activity"}
|
||||||
|
onclick={() => (activeTab = "activity")}
|
||||||
|
>
|
||||||
|
Activity
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab"
|
||||||
|
class:active={activeTab === "files"}
|
||||||
|
onclick={() => (activeTab = "files")}
|
||||||
|
>
|
||||||
|
Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if activeTab === "activity"}
|
||||||
|
<ActivityFeed
|
||||||
|
versions={enrichedHistory}
|
||||||
|
loading={loadingHistory}
|
||||||
|
hasMore={historyHasMore}
|
||||||
|
onLoadMore={loadMoreHistory}
|
||||||
|
onSelectDocument={selectDocument}
|
||||||
|
onTimeTravel={(id) => {
|
||||||
|
timeSliderValue = id >= maxUpdateId ? null : id;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="file-list">
|
||||||
|
{#each latestDocuments
|
||||||
|
.filter((d) => showDeleted || !d.isDeleted)
|
||||||
|
.sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc}
|
||||||
|
<button
|
||||||
|
class="file-row"
|
||||||
|
class:deleted={doc.isDeleted}
|
||||||
|
onclick={() =>
|
||||||
|
selectDocument(doc.documentId)}
|
||||||
|
>
|
||||||
|
<span class="file-icon"
|
||||||
|
>{doc.isDeleted
|
||||||
|
? "🗑"
|
||||||
|
: "📄"}</span
|
||||||
|
>
|
||||||
|
<span class="file-path"
|
||||||
|
>{doc.relativePath}</span
|
||||||
|
>
|
||||||
|
<span class="file-meta">
|
||||||
|
{formatBytes(doc.contentSize)}
|
||||||
|
·
|
||||||
|
{doc.userId}
|
||||||
|
·
|
||||||
|
{relativeTime(doc.updatedDate)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-layout {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
min-width: 280px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-search input {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-controls {
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-tree {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slider-container {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 16px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row.deleted {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row.deleted .file-path {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-path {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
288
frontend/history-ui/src/components/DiffView.svelte
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
oldContent: string;
|
||||||
|
newContent: string;
|
||||||
|
oldLabel: string;
|
||||||
|
newLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { oldContent, newContent, oldLabel, newLabel }: Props = $props();
|
||||||
|
|
||||||
|
interface DiffLine {
|
||||||
|
type: "add" | "remove" | "context";
|
||||||
|
content: string;
|
||||||
|
oldLineNo: number | null;
|
||||||
|
newLineNo: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let diffLines = $derived.by((): DiffLine[] => {
|
||||||
|
const oldLines = oldContent.split("\n");
|
||||||
|
const newLines = newContent.split("\n");
|
||||||
|
|
||||||
|
// Simple line-by-line diff using LCS
|
||||||
|
const lines: DiffLine[] = [];
|
||||||
|
const lcs = computeLCS(oldLines, newLines);
|
||||||
|
|
||||||
|
let oi = 0;
|
||||||
|
let ni = 0;
|
||||||
|
let oldLineNo = 1;
|
||||||
|
let newLineNo = 1;
|
||||||
|
|
||||||
|
for (const match of lcs) {
|
||||||
|
// Remove lines before match
|
||||||
|
while (oi < match.oldIndex) {
|
||||||
|
lines.push({
|
||||||
|
type: "remove",
|
||||||
|
content: oldLines[oi],
|
||||||
|
oldLineNo: oldLineNo++,
|
||||||
|
newLineNo: null
|
||||||
|
});
|
||||||
|
oi++;
|
||||||
|
}
|
||||||
|
// Add lines before match
|
||||||
|
while (ni < match.newIndex) {
|
||||||
|
lines.push({
|
||||||
|
type: "add",
|
||||||
|
content: newLines[ni],
|
||||||
|
oldLineNo: null,
|
||||||
|
newLineNo: newLineNo++
|
||||||
|
});
|
||||||
|
ni++;
|
||||||
|
}
|
||||||
|
// Context line
|
||||||
|
lines.push({
|
||||||
|
type: "context",
|
||||||
|
content: oldLines[oi],
|
||||||
|
oldLineNo: oldLineNo++,
|
||||||
|
newLineNo: newLineNo++
|
||||||
|
});
|
||||||
|
oi++;
|
||||||
|
ni++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining removes
|
||||||
|
while (oi < oldLines.length) {
|
||||||
|
lines.push({
|
||||||
|
type: "remove",
|
||||||
|
content: oldLines[oi],
|
||||||
|
oldLineNo: oldLineNo++,
|
||||||
|
newLineNo: null
|
||||||
|
});
|
||||||
|
oi++;
|
||||||
|
}
|
||||||
|
// Remaining adds
|
||||||
|
while (ni < newLines.length) {
|
||||||
|
lines.push({
|
||||||
|
type: "add",
|
||||||
|
content: newLines[ni],
|
||||||
|
oldLineNo: null,
|
||||||
|
newLineNo: newLineNo++
|
||||||
|
});
|
||||||
|
ni++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
});
|
||||||
|
|
||||||
|
let stats = $derived({
|
||||||
|
added: diffLines.filter((l) => l.type === "add").length,
|
||||||
|
removed: diffLines.filter((l) => l.type === "remove").length
|
||||||
|
});
|
||||||
|
|
||||||
|
interface LCSMatch {
|
||||||
|
oldIndex: number;
|
||||||
|
newIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLCS(a: string[], b: string[]): LCSMatch[] {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
|
||||||
|
// For large files, use a simpler approach
|
||||||
|
if (m * n > 1_000_000) {
|
||||||
|
return simpleDiff(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||||
|
new Array(n + 1).fill(0)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack
|
||||||
|
const matches: LCSMatch[] = [];
|
||||||
|
let i = m;
|
||||||
|
let j = n;
|
||||||
|
while (i > 0 && j > 0) {
|
||||||
|
if (a[i - 1] === b[j - 1]) {
|
||||||
|
matches.unshift({ oldIndex: i - 1, newIndex: j - 1 });
|
||||||
|
i--;
|
||||||
|
j--;
|
||||||
|
} else if (dp[i - 1][j] > dp[i][j - 1]) {
|
||||||
|
i--;
|
||||||
|
} else {
|
||||||
|
j--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function simpleDiff(a: string[], b: string[]): LCSMatch[] {
|
||||||
|
// Hash-based matching for large files
|
||||||
|
const bMap = new Map<string, number[]>();
|
||||||
|
for (let j = 0; j < b.length; j++) {
|
||||||
|
const arr = bMap.get(b[j]);
|
||||||
|
if (arr) arr.push(j);
|
||||||
|
else bMap.set(b[j], [j]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: LCSMatch[] = [];
|
||||||
|
let lastJ = -1;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
const candidates = bMap.get(a[i]);
|
||||||
|
if (!candidates) continue;
|
||||||
|
for (const j of candidates) {
|
||||||
|
if (j > lastJ) {
|
||||||
|
matches.push({ oldIndex: i, newIndex: j });
|
||||||
|
lastJ = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="diff-view">
|
||||||
|
<div class="diff-header">
|
||||||
|
<span class="diff-label">{oldLabel}</span>
|
||||||
|
<span class="diff-arrow">→</span>
|
||||||
|
<span class="diff-label">{newLabel}</span>
|
||||||
|
<span class="diff-stats">
|
||||||
|
<span class="diff-added">+{stats.added}</span>
|
||||||
|
<span class="diff-removed">-{stats.removed}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="diff-content">
|
||||||
|
{#each diffLines as line}
|
||||||
|
<div class="diff-line {line.type}">
|
||||||
|
<span class="line-no old-no">
|
||||||
|
{line.oldLineNo ?? ""}
|
||||||
|
</span>
|
||||||
|
<span class="line-no new-no">
|
||||||
|
{line.newLineNo ?? ""}
|
||||||
|
</span>
|
||||||
|
<span class="line-marker">
|
||||||
|
{#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if}
|
||||||
|
</span>
|
||||||
|
<span class="line-content">{line.content}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diff-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-arrow {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-stats {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-added {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-removed {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line {
|
||||||
|
display: flex;
|
||||||
|
white-space: pre;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.add {
|
||||||
|
background: var(--green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.remove {
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-no {
|
||||||
|
display: inline-block;
|
||||||
|
width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 8px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
user-select: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-marker {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.add .line-marker {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-line.remove .line-marker {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-content {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
729
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
729
frontend/history-ui/src/components/DocumentDetail.svelte
Normal file
|
|
@ -0,0 +1,729 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
auth,
|
||||||
|
toasts,
|
||||||
|
relativeTime,
|
||||||
|
absoluteTime,
|
||||||
|
formatBytes,
|
||||||
|
inferAction,
|
||||||
|
isTextFile,
|
||||||
|
isImageFile,
|
||||||
|
fileExtension
|
||||||
|
} from "../lib/stores.svelte";
|
||||||
|
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
||||||
|
import type { DocumentVersion } from "../lib/types/DocumentVersion";
|
||||||
|
import type { ActionType } from "../lib/view-types";
|
||||||
|
import DiffView from "./DiffView.svelte";
|
||||||
|
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
documentId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onRestore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { documentId, onClose, onRestore }: Props = $props();
|
||||||
|
|
||||||
|
let versions = $state<DocumentVersionWithoutContent[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let selectedVersion = $state<DocumentVersionWithoutContent | null>(null);
|
||||||
|
let loadedContent = $state<string | null>(null);
|
||||||
|
let loadedContentBytes = $state<ArrayBuffer | null>(null);
|
||||||
|
let loadingContent = $state(false);
|
||||||
|
let activeTab = $state<"preview" | "diff">("preview");
|
||||||
|
|
||||||
|
// Diff state
|
||||||
|
let diffOldContent = $state<string | null>(null);
|
||||||
|
let diffNewContent = $state<string | null>(null);
|
||||||
|
let diffOldLabel = $state("");
|
||||||
|
let diffNewLabel = $state("");
|
||||||
|
|
||||||
|
// Restore state
|
||||||
|
let showRestoreDialog = $state(false);
|
||||||
|
let restoreTarget = $state<DocumentVersionWithoutContent | null>(null);
|
||||||
|
let restoring = $state(false);
|
||||||
|
|
||||||
|
let latest = $derived(versions.at(-1) ?? null);
|
||||||
|
let isDeleted = $derived(latest?.isDeleted ?? false);
|
||||||
|
let currentPath = $derived(latest?.relativePath ?? "");
|
||||||
|
|
||||||
|
// Derive action types
|
||||||
|
let versionEvents = $derived(
|
||||||
|
versions.map((v, i) => ({
|
||||||
|
version: v,
|
||||||
|
action: inferAction(v, i > 0 ? versions[i - 1] : undefined) as ActionType,
|
||||||
|
previousPath: i > 0 && versions[i - 1].relativePath !== v.relativePath
|
||||||
|
? versions[i - 1].relativePath
|
||||||
|
: undefined
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadVersions() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
versions = await api.fetchDocumentVersions(documentId);
|
||||||
|
// Auto-select latest
|
||||||
|
if (versions.length > 0) {
|
||||||
|
await selectVersion(versions.at(-1)!);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load document versions", "error");
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectVersion(v: DocumentVersionWithoutContent) {
|
||||||
|
selectedVersion = v;
|
||||||
|
activeTab = "preview";
|
||||||
|
diffOldContent = null;
|
||||||
|
diffNewContent = null;
|
||||||
|
loadingContent = true;
|
||||||
|
loadedContent = null;
|
||||||
|
loadedContentBytes = null;
|
||||||
|
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isTextFile(v.relativePath) || fileExtension(v.relativePath) === "") {
|
||||||
|
const fullVersion = await api.fetchDocumentVersion(
|
||||||
|
documentId,
|
||||||
|
v.vaultUpdateId
|
||||||
|
);
|
||||||
|
const bytes = Uint8Array.from(atob(fullVersion.contentBase64), c => c.charCodeAt(0));
|
||||||
|
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||||
|
loadedContent = decoder.decode(bytes);
|
||||||
|
loadedContentBytes = bytes.buffer;
|
||||||
|
} else if (isImageFile(v.relativePath)) {
|
||||||
|
loadedContentBytes = await api.fetchDocumentVersionContent(
|
||||||
|
documentId,
|
||||||
|
v.vaultUpdateId
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
loadedContentBytes = await api.fetchDocumentVersionContent(
|
||||||
|
documentId,
|
||||||
|
v.vaultUpdateId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load content", "error");
|
||||||
|
} finally {
|
||||||
|
loadingContent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDiff(v: DocumentVersionWithoutContent, idx: number) {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api || idx === 0) return;
|
||||||
|
|
||||||
|
activeTab = "diff";
|
||||||
|
loadingContent = true;
|
||||||
|
|
||||||
|
const prev = versions[idx - 1];
|
||||||
|
try {
|
||||||
|
const [oldVer, newVer] = await Promise.all([
|
||||||
|
api.fetchDocumentVersion(documentId, prev.vaultUpdateId),
|
||||||
|
api.fetchDocumentVersion(documentId, v.vaultUpdateId)
|
||||||
|
]);
|
||||||
|
const decode = (b64: string) => {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||||||
|
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||||
|
};
|
||||||
|
diffOldContent = decode(oldVer.contentBase64);
|
||||||
|
diffNewContent = decode(newVer.contentBase64);
|
||||||
|
diffOldLabel = `v${prev.vaultUpdateId}`;
|
||||||
|
diffNewLabel = `v${v.vaultUpdateId}`;
|
||||||
|
} catch {
|
||||||
|
toasts.add("Failed to load diff", "error");
|
||||||
|
} finally {
|
||||||
|
loadingContent = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmRestore(v: DocumentVersionWithoutContent) {
|
||||||
|
restoreTarget = v;
|
||||||
|
showRestoreDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeRestore() {
|
||||||
|
const api = auth.api;
|
||||||
|
if (!api || !restoreTarget || !latest) return;
|
||||||
|
restoring = true;
|
||||||
|
try {
|
||||||
|
// Restore = re-submit the target version's bytes at its path
|
||||||
|
// as if it were a fresh edit. `update_document` short-circuits
|
||||||
|
// on `is_deleted`, so resurrecting a deleted doc has to go
|
||||||
|
// through `create_document`; a live doc takes the normal
|
||||||
|
// update path with the current latest as its parent.
|
||||||
|
const bytes = await api.fetchDocumentVersionContent(
|
||||||
|
documentId,
|
||||||
|
restoreTarget.vaultUpdateId
|
||||||
|
);
|
||||||
|
if (latest.isDeleted) {
|
||||||
|
await api.createDocument(
|
||||||
|
latest.vaultUpdateId,
|
||||||
|
restoreTarget.relativePath,
|
||||||
|
bytes
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await api.updateBinaryDocument(
|
||||||
|
documentId,
|
||||||
|
latest.vaultUpdateId,
|
||||||
|
restoreTarget.relativePath,
|
||||||
|
bytes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
toasts.add(
|
||||||
|
`Restored to version #${restoreTarget.vaultUpdateId}`,
|
||||||
|
"success"
|
||||||
|
);
|
||||||
|
showRestoreDialog = false;
|
||||||
|
restoreTarget = null;
|
||||||
|
onRestore();
|
||||||
|
await loadVersions();
|
||||||
|
} catch (e) {
|
||||||
|
toasts.add(`Restore failed: ${e}`, "error");
|
||||||
|
} finally {
|
||||||
|
restoring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageUrl(buffer: ArrayBuffer, path: string): string {
|
||||||
|
const ext = fileExtension(path);
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
svg: "image/svg+xml",
|
||||||
|
ico: "image/x-icon",
|
||||||
|
bmp: "image/bmp"
|
||||||
|
};
|
||||||
|
const mime = mimeMap[ext] ?? "application/octet-stream";
|
||||||
|
const blob = new Blob([buffer], { type: mime });
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadVersions();
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
created: "var(--green)",
|
||||||
|
updated: "var(--blue)",
|
||||||
|
renamed: "var(--orange)",
|
||||||
|
deleted: "var(--red)",
|
||||||
|
restored: "var(--purple)"
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionBgColors: Record<string, string> = {
|
||||||
|
created: "var(--green-bg)",
|
||||||
|
updated: "var(--blue-bg)",
|
||||||
|
renamed: "var(--orange-bg)",
|
||||||
|
deleted: "var(--red-bg)",
|
||||||
|
restored: "var(--purple-bg)"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="detail-header">
|
||||||
|
<button class="back-btn" onclick={onClose} title="Back">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="header-info">
|
||||||
|
<div class="header-path">
|
||||||
|
<span class="path-text" class:deleted-path={isDeleted}>
|
||||||
|
{currentPath}
|
||||||
|
</span>
|
||||||
|
{#if isDeleted}
|
||||||
|
<span class="status-badge deleted-badge">Deleted</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status-badge active-badge">Active</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="header-meta">
|
||||||
|
<span class="doc-id" title={documentId}>
|
||||||
|
{documentId.substring(0, 8)}...
|
||||||
|
</span>
|
||||||
|
{#if latest}
|
||||||
|
<span>·</span>
|
||||||
|
<span>{versions.length} version{versions.length !== 1 ? "s" : ""}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>Last by {latest.userId}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="detail-loading">Loading versions...</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Content area -->
|
||||||
|
<div class="detail-body">
|
||||||
|
<div class="content-panel">
|
||||||
|
{#if selectedVersion}
|
||||||
|
<div class="content-tabs">
|
||||||
|
<button
|
||||||
|
class="content-tab"
|
||||||
|
class:active={activeTab === "preview"}
|
||||||
|
onclick={() => (activeTab = "preview")}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="content-tab"
|
||||||
|
class:active={activeTab === "diff"}
|
||||||
|
onclick={() => {
|
||||||
|
if (selectedVersion) {
|
||||||
|
const idx = versions.indexOf(selectedVersion);
|
||||||
|
if (idx > 0) showDiff(selectedVersion, idx);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={versions.indexOf(selectedVersion) === 0}
|
||||||
|
>
|
||||||
|
Diff
|
||||||
|
</button>
|
||||||
|
<div class="content-tab-spacer"></div>
|
||||||
|
<span class="viewing-label">
|
||||||
|
Viewing v#{selectedVersion.vaultUpdateId}
|
||||||
|
·
|
||||||
|
{relativeTime(selectedVersion.updatedDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-view">
|
||||||
|
{#if loadingContent}
|
||||||
|
<div class="content-loading">Loading content...</div>
|
||||||
|
{:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null}
|
||||||
|
<DiffView
|
||||||
|
oldContent={diffOldContent}
|
||||||
|
newContent={diffNewContent}
|
||||||
|
oldLabel={diffOldLabel}
|
||||||
|
newLabel={diffNewLabel}
|
||||||
|
/>
|
||||||
|
{:else if activeTab === "preview"}
|
||||||
|
{#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""}
|
||||||
|
<pre class="text-content">{loadedContent ?? ""}</pre>
|
||||||
|
{:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes}
|
||||||
|
<div class="image-preview">
|
||||||
|
<img
|
||||||
|
src={getImageUrl(loadedContentBytes, selectedVersion.relativePath)}
|
||||||
|
alt={selectedVersion.relativePath}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="binary-placeholder">
|
||||||
|
<div class="binary-icon">📦</div>
|
||||||
|
<div class="binary-label">Binary file</div>
|
||||||
|
<div class="binary-size">
|
||||||
|
{formatBytes(selectedVersion.contentSize)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Version timeline -->
|
||||||
|
<div class="version-panel">
|
||||||
|
<div class="version-panel-header">Version History</div>
|
||||||
|
<div class="version-list">
|
||||||
|
{#each [...versionEvents].reverse() as event, i}
|
||||||
|
{@const v = event.version}
|
||||||
|
{@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId}
|
||||||
|
<div class="version-item" class:selected={isSelected}>
|
||||||
|
<button
|
||||||
|
class="version-main"
|
||||||
|
onclick={() => selectVersion(v)}
|
||||||
|
>
|
||||||
|
<div class="version-left">
|
||||||
|
<span class="version-id">#{v.vaultUpdateId}</span>
|
||||||
|
<span
|
||||||
|
class="version-action"
|
||||||
|
style="color: {actionColors[event.action]}; background: {actionBgColors[event.action]}"
|
||||||
|
>
|
||||||
|
{event.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="version-right">
|
||||||
|
<span class="version-user">{v.userId}</span>
|
||||||
|
<span
|
||||||
|
class="version-time"
|
||||||
|
title={absoluteTime(v.updatedDate)}
|
||||||
|
>
|
||||||
|
{relativeTime(v.updatedDate)}
|
||||||
|
</span>
|
||||||
|
<span class="version-size">{formatBytes(v.contentSize)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{#if event.previousPath}
|
||||||
|
<div class="version-rename">
|
||||||
|
{event.previousPath} → {v.relativePath}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="version-actions">
|
||||||
|
{#if i < versionEvents.length - 1}
|
||||||
|
<button
|
||||||
|
class="version-btn"
|
||||||
|
onclick={() => {
|
||||||
|
const realIdx = versions.indexOf(v);
|
||||||
|
showDiff(v, realIdx);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diff
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if v !== latest}
|
||||||
|
<button
|
||||||
|
class="version-btn restore-btn"
|
||||||
|
onclick={() => confirmRestore(v)}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showRestoreDialog && restoreTarget}
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Restore Version"
|
||||||
|
message={`Restore "${currentPath}" to version #${restoreTarget.vaultUpdateId} from ${absoluteTime(restoreTarget.updatedDate)}? This creates a new version with the old content. Current content is preserved in history.`}
|
||||||
|
confirmLabel="Restore"
|
||||||
|
destructive={false}
|
||||||
|
loading={restoring}
|
||||||
|
onConfirm={executeRestore}
|
||||||
|
onCancel={() => {
|
||||||
|
showRestoreDialog = false;
|
||||||
|
restoreTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-path {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-text {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-path {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-badge {
|
||||||
|
color: var(--green);
|
||||||
|
background: var(--green-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted-badge {
|
||||||
|
color: var(--red);
|
||||||
|
background: var(--red-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-loading {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-tab-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewing-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-view {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-loading {
|
||||||
|
padding: 48px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
padding: 16px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-placeholder {
|
||||||
|
padding: 64px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-label {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.binary-size {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Version panel */
|
||||||
|
.version-panel {
|
||||||
|
width: 320px;
|
||||||
|
min-width: 320px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-panel-header {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item {
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
padding: 8px 12px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-item.selected {
|
||||||
|
background: var(--blue-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-main {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-id {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-action {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-rename {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--orange);
|
||||||
|
font-family: var(--mono);
|
||||||
|
margin: 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-btn {
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
124
frontend/history-ui/src/components/FileTree.svelte
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { TreeNode } from "../lib/view-types";
|
||||||
|
import FileTree from "./FileTree.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: TreeNode;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (documentId: string) => void;
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { node, selectedId, onSelect, depth = 0 }: Props = $props();
|
||||||
|
|
||||||
|
let expanded = $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
function toggle(path: string) {
|
||||||
|
expanded[path] = !expanded[path];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(path: string): boolean {
|
||||||
|
return expanded[path] ?? true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if node.isFolder && depth === 0}
|
||||||
|
{#each node.children as child}
|
||||||
|
<FileTree
|
||||||
|
node={child}
|
||||||
|
{selectedId}
|
||||||
|
{onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{:else if node.isFolder}
|
||||||
|
<div class="tree-folder">
|
||||||
|
<button
|
||||||
|
class="tree-item folder"
|
||||||
|
style="padding-left: {depth * 16}px"
|
||||||
|
onclick={() => toggle(node.path)}
|
||||||
|
>
|
||||||
|
<span class="expand-icon"
|
||||||
|
>{isExpanded(node.path) ? "▾" : "▸"}</span
|
||||||
|
>
|
||||||
|
<span class="folder-icon">📁</span>
|
||||||
|
<span class="node-name">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{#if isExpanded(node.path)}
|
||||||
|
{#each node.children as child}
|
||||||
|
<FileTree
|
||||||
|
node={child}
|
||||||
|
{selectedId}
|
||||||
|
{onSelect}
|
||||||
|
depth={depth + 1}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="tree-item file"
|
||||||
|
class:selected={node.document?.documentId === selectedId}
|
||||||
|
class:deleted={node.isDeleted}
|
||||||
|
style="padding-left: {depth * 16 + 8}px"
|
||||||
|
onclick={() =>
|
||||||
|
node.document && onSelect(node.document.documentId)}
|
||||||
|
>
|
||||||
|
<span class="file-icon">{node.isDeleted ? "○" : "●"}</span>
|
||||||
|
<span class="node-name">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tree-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 3px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.selected {
|
||||||
|
background: var(--blue-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.deleted {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-item.deleted .node-name {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
144
frontend/history-ui/src/components/Header.svelte
Normal file
144
frontend/history-ui/src/components/Header.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth } from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vaultId: string;
|
||||||
|
serverVersion: string;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { vaultId, serverVersion, onRefresh }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
<span class="header-title">VaultLink</span>
|
||||||
|
<span class="header-sep">/</span>
|
||||||
|
<span class="header-vault">{vaultId}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="server-version">v{serverVersion}</span>
|
||||||
|
<button class="header-btn" onclick={onRefresh} title="Refresh">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if auth.availableVaults.length > 1}
|
||||||
|
<button
|
||||||
|
class="header-btn"
|
||||||
|
onclick={() => auth.deselectVault()}
|
||||||
|
title="Switch vault"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="header-btn"
|
||||||
|
onclick={() => auth.logout()}
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||||
|
<polyline points="16 17 21 12 16 7" />
|
||||||
|
<line x1="21" y1="12" x2="9" y2="12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-sep {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-vault {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-version {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
176
frontend/history-ui/src/components/Login.svelte
Normal file
176
frontend/history-ui/src/components/Login.svelte
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth } from "../lib/stores.svelte";
|
||||||
|
import { listVaults } from "../lib/api";
|
||||||
|
|
||||||
|
let token = $state("");
|
||||||
|
let error = $state("");
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!token.trim()) {
|
||||||
|
error = "Token is required.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error = "";
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const response = await listVaults(token.trim());
|
||||||
|
auth.authenticate(
|
||||||
|
token.trim(),
|
||||||
|
response.userName,
|
||||||
|
response.vaults
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
error = "Authentication failed. Check your token.";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<h1>VaultLink</h1>
|
||||||
|
</div>
|
||||||
|
<p class="subtitle">Vault History Browser</p>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
<span>Token</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={token}
|
||||||
|
placeholder="Enter your access token"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary" disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<span class="btn-spinner"></span>
|
||||||
|
Connecting...
|
||||||
|
{:else}
|
||||||
|
Connect
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 32px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--red);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--red-bg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: background 0.15s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
191
frontend/history-ui/src/components/TimeSlider.svelte
Normal file
|
|
@ -0,0 +1,191 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { DocumentVersionWithoutContent } from "../lib/types/DocumentVersionWithoutContent";
|
||||||
|
import { relativeTime, absoluteTime } from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
value: number | null;
|
||||||
|
versions: DocumentVersionWithoutContent[];
|
||||||
|
onchange: (value: number | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { min, max, value, versions, onchange }: Props = $props();
|
||||||
|
|
||||||
|
let isNow = $derived(value === null || value >= max);
|
||||||
|
|
||||||
|
function handleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement;
|
||||||
|
const v = parseInt(target.value, 10);
|
||||||
|
if (v >= max) {
|
||||||
|
onchange(null);
|
||||||
|
} else {
|
||||||
|
onchange(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapToNow() {
|
||||||
|
onchange(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentVersion = $derived(
|
||||||
|
value !== null
|
||||||
|
? versions.find((v) => v.vaultUpdateId === value) ??
|
||||||
|
versions.reduce(
|
||||||
|
(closest, v) =>
|
||||||
|
Math.abs(v.vaultUpdateId - (value ?? max)) <
|
||||||
|
Math.abs(
|
||||||
|
closest.vaultUpdateId - (value ?? max)
|
||||||
|
)
|
||||||
|
? v
|
||||||
|
: closest,
|
||||||
|
versions[0]
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="time-slider">
|
||||||
|
<div class="slider-label">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span class="label-text">Time Travel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-track">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
value={value ?? max}
|
||||||
|
oninput={handleInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="slider-info">
|
||||||
|
{#if isNow}
|
||||||
|
<span class="now-badge">Now</span>
|
||||||
|
{:else if currentVersion}
|
||||||
|
<span
|
||||||
|
class="time-info"
|
||||||
|
title={absoluteTime(currentVersion.updatedDate)}
|
||||||
|
>
|
||||||
|
#{value}
|
||||||
|
·
|
||||||
|
{relativeTime(currentVersion.updatedDate)}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="time-info">#{value}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isNow}
|
||||||
|
<button class="snap-btn" onclick={snapToNow} title="Back to now">
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.time-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 2px;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track input[type="range"]::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-track input[type="range"]::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-info {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.now-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--green);
|
||||||
|
background: var(--green-bg);
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-info {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-btn {
|
||||||
|
padding: 4px;
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: background 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snap-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
80
frontend/history-ui/src/components/ToastContainer.svelte
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { toasts } from "../lib/stores.svelte";
|
||||||
|
|
||||||
|
const typeColors: Record<string, string> = {
|
||||||
|
success: "var(--green)",
|
||||||
|
error: "var(--red)",
|
||||||
|
info: "var(--accent)"
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if toasts.items.length > 0}
|
||||||
|
<div class="toast-container">
|
||||||
|
{#each toasts.items as toast (toast.id)}
|
||||||
|
<div
|
||||||
|
class="toast"
|
||||||
|
style="border-left-color: {typeColors[toast.type]}"
|
||||||
|
>
|
||||||
|
<span class="toast-message">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
class="toast-dismiss"
|
||||||
|
onclick={() => toasts.dismiss(toast.id)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left-width: 3px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: slide-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-dismiss {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-dismiss:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
198
frontend/history-ui/src/components/VaultPicker.svelte
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { auth } from "../lib/stores.svelte";
|
||||||
|
import { relativeTime } from "../lib/stores.svelte";
|
||||||
|
import type { VaultInfo } from "../lib/types/VaultInfo";
|
||||||
|
|
||||||
|
function select(vault: VaultInfo) {
|
||||||
|
auth.selectVault(vault.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStats(vault: VaultInfo): string {
|
||||||
|
const docs = vault.documentCount === 1
|
||||||
|
? "1 document"
|
||||||
|
: `${vault.documentCount} documents`;
|
||||||
|
if (!vault.createdAt) return docs;
|
||||||
|
return `${docs} · created ${relativeTime(vault.createdAt)}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="picker-page">
|
||||||
|
<div class="picker-card">
|
||||||
|
<div class="picker-header">
|
||||||
|
<div class="logo">
|
||||||
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h1>Select a vault</h1>
|
||||||
|
<p class="user-info">
|
||||||
|
Signed in as <strong>{auth.userName}</strong>
|
||||||
|
<button class="logout-link" onclick={() => auth.logout()}>Sign out</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if auth.availableVaults.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<p>No vaults found</p>
|
||||||
|
<p class="empty-hint">
|
||||||
|
Vaults are created when a sync client first connects.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="vault-list">
|
||||||
|
{#each auth.availableVaults as vault}
|
||||||
|
<li>
|
||||||
|
<button class="vault-item" onclick={() => select(vault)}>
|
||||||
|
<svg class="vault-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<div class="vault-details">
|
||||||
|
<span class="vault-name">{vault.name}</span>
|
||||||
|
<span class="vault-stats">{formatStats(vault)}</span>
|
||||||
|
</div>
|
||||||
|
<svg class="vault-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline points="9 18 15 12 9 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.picker-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header {
|
||||||
|
padding: 32px 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo svg {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-link {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-link:hover {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-list {
|
||||||
|
list-style: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-list li + li {
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 24px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-item:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-icon {
|
||||||
|
color: var(--text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-details {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-stats {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vault-arrow {
|
||||||
|
color: var(--text-subtle);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-subtle);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
146
frontend/history-ui/src/lib/api.ts
Normal file
146
frontend/history-ui/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse";
|
||||||
|
import type { DocumentVersion } from "./types/DocumentVersion";
|
||||||
|
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||||
|
import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse";
|
||||||
|
import type { ListVaultsResponse } from "./types/ListVaultsResponse";
|
||||||
|
import type { PingResponse } from "./types/PingResponse";
|
||||||
|
import type { VaultHistoryResponse } from "./types/VaultHistoryResponse";
|
||||||
|
|
||||||
|
async function fetchJsonWithToken<T>(
|
||||||
|
path: string,
|
||||||
|
token: string,
|
||||||
|
init?: RequestInit
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"device-id": "history-ui",
|
||||||
|
...init?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listVaults(token: string): Promise<ListVaultsResponse> {
|
||||||
|
return fetchJsonWithToken("/vaults", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
constructor(
|
||||||
|
private vaultId: string,
|
||||||
|
private token: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get baseUrl(): string {
|
||||||
|
return `/vaults/${encodeURIComponent(this.vaultId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
return fetchJsonWithToken(path, this.token, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ping(): Promise<PingResponse> {
|
||||||
|
return this.fetchJson(`${this.baseUrl}/ping`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLatestDocuments(): Promise<FetchLatestDocumentsResponse> {
|
||||||
|
return this.fetchJson(`${this.baseUrl}/documents`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDocumentVersions(
|
||||||
|
documentId: string
|
||||||
|
): Promise<DocumentVersionWithoutContent[]> {
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/versions`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDocumentVersion(
|
||||||
|
documentId: string,
|
||||||
|
vaultUpdateId: number
|
||||||
|
): Promise<DocumentVersion> {
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchDocumentVersionContent(
|
||||||
|
documentId: string,
|
||||||
|
vaultUpdateId: number
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${this.token}`,
|
||||||
|
"device-id": "history-ui"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchVaultHistory(
|
||||||
|
limit?: number,
|
||||||
|
beforeUpdateId?: number
|
||||||
|
): Promise<VaultHistoryResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (limit !== undefined) params.set("limit", String(limit));
|
||||||
|
if (beforeUpdateId !== undefined)
|
||||||
|
params.set("before_update_id", String(beforeUpdateId));
|
||||||
|
const qs = params.toString();
|
||||||
|
return this.fetchJson(`${this.baseUrl}/history${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a new version of an existing (non-deleted) document. The
|
||||||
|
* server treats this like any other edit — server-side merging,
|
||||||
|
* path dedupe, and broadcast still apply. Used by the UI to restore
|
||||||
|
* an old version by re-submitting its bytes on top of the latest.
|
||||||
|
*/
|
||||||
|
async updateBinaryDocument(
|
||||||
|
documentId: string,
|
||||||
|
parentVersionId: number,
|
||||||
|
relativePath: string,
|
||||||
|
content: ArrayBuffer
|
||||||
|
): Promise<DocumentUpdateResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("parent_version_id", String(parentVersionId));
|
||||||
|
form.append("relative_path", relativePath);
|
||||||
|
form.append("content", new Blob([content]));
|
||||||
|
return this.fetchJson(
|
||||||
|
`${this.baseUrl}/documents/${documentId}/binary`,
|
||||||
|
{ method: "PUT", body: form }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new document. Used by the UI to restore a deleted
|
||||||
|
* document: `update_document` short-circuits on `is_deleted`, so
|
||||||
|
* resurrection has to go through `create_document` — which detects
|
||||||
|
* an existing doc at the same path, merges or dedupes as needed,
|
||||||
|
* and returns the resulting version.
|
||||||
|
*/
|
||||||
|
async createDocument(
|
||||||
|
lastSeenVaultUpdateId: number,
|
||||||
|
relativePath: string,
|
||||||
|
content: ArrayBuffer
|
||||||
|
): Promise<DocumentUpdateResponse> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId));
|
||||||
|
form.append("relative_path", relativePath);
|
||||||
|
form.append("content", new Blob([content]));
|
||||||
|
return this.fetchJson(`${this.baseUrl}/documents`, {
|
||||||
|
method: "POST",
|
||||||
|
body: form
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
290
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
290
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
import { ApiClient } from "./api";
|
||||||
|
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||||
|
import type { VaultInfo } from "./types/VaultInfo";
|
||||||
|
import type { VersionEvent, ActionType, TreeNode } from "./view-types";
|
||||||
|
|
||||||
|
class AuthStore {
|
||||||
|
token = $state("");
|
||||||
|
userName = $state("");
|
||||||
|
vaultId = $state("");
|
||||||
|
serverVersion = $state("");
|
||||||
|
availableVaults = $state<VaultInfo[]>([]);
|
||||||
|
isAuthenticated = $state(false);
|
||||||
|
api = $state<ApiClient | null>(null);
|
||||||
|
|
||||||
|
authenticate(token: string, userName: string, vaults: VaultInfo[]) {
|
||||||
|
this.token = token;
|
||||||
|
this.userName = userName;
|
||||||
|
this.availableVaults = vaults;
|
||||||
|
sessionStorage.setItem("vaultlink_token", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectVault(vaultId: string) {
|
||||||
|
this.vaultId = vaultId;
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
this.api = new ApiClient(vaultId, this.token);
|
||||||
|
sessionStorage.setItem("vaultlink_vault", vaultId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectVault() {
|
||||||
|
this.vaultId = "";
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
this.api = null;
|
||||||
|
sessionStorage.removeItem("vaultlink_vault");
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.token = "";
|
||||||
|
this.userName = "";
|
||||||
|
this.vaultId = "";
|
||||||
|
this.serverVersion = "";
|
||||||
|
this.availableVaults = [];
|
||||||
|
this.isAuthenticated = false;
|
||||||
|
this.api = null;
|
||||||
|
sessionStorage.removeItem("vaultlink_token");
|
||||||
|
sessionStorage.removeItem("vaultlink_vault");
|
||||||
|
}
|
||||||
|
|
||||||
|
tryRestore(): { token: string; vaultId?: string } | null {
|
||||||
|
const token = sessionStorage.getItem("vaultlink_token");
|
||||||
|
if (!token) return null;
|
||||||
|
const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined;
|
||||||
|
return { token, vaultId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = new AuthStore();
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
export type View =
|
||||||
|
| { kind: "dashboard" }
|
||||||
|
| { kind: "document"; documentId: string };
|
||||||
|
|
||||||
|
class NavStore {
|
||||||
|
current = $state<View>({ kind: "dashboard" });
|
||||||
|
|
||||||
|
goto(view: View) {
|
||||||
|
this.current = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
goHome() {
|
||||||
|
this.current = { kind: "dashboard" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nav = new NavStore();
|
||||||
|
|
||||||
|
// Toasts
|
||||||
|
interface Toast {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
type: "success" | "error" | "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
class ToastStore {
|
||||||
|
items = $state<Toast[]>([]);
|
||||||
|
private nextId = 0;
|
||||||
|
|
||||||
|
add(message: string, type: Toast["type"] = "info") {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.items.push({ id, message, type });
|
||||||
|
setTimeout(() => this.dismiss(id), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(id: number) {
|
||||||
|
this.items = this.items.filter((t) => t.id !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toasts = new ToastStore();
|
||||||
|
|
||||||
|
// Utilities
|
||||||
|
|
||||||
|
export function inferAction(
|
||||||
|
version: DocumentVersionWithoutContent,
|
||||||
|
previousVersion?: DocumentVersionWithoutContent
|
||||||
|
): ActionType {
|
||||||
|
if (version.isDeleted) return "deleted";
|
||||||
|
if (!previousVersion) return "created";
|
||||||
|
if (previousVersion.isDeleted && !version.isDeleted) return "restored";
|
||||||
|
if (previousVersion.relativePath !== version.relativePath) return "renamed";
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichVersions(
|
||||||
|
versions: DocumentVersionWithoutContent[]
|
||||||
|
): VersionEvent[] {
|
||||||
|
// versions should be sorted by vaultUpdateId ascending
|
||||||
|
const sorted = [...versions].sort(
|
||||||
|
(a, b) => a.vaultUpdateId - b.vaultUpdateId
|
||||||
|
);
|
||||||
|
const byDoc = new Map<string, DocumentVersionWithoutContent[]>();
|
||||||
|
for (const v of sorted) {
|
||||||
|
let arr = byDoc.get(v.documentId);
|
||||||
|
if (!arr) {
|
||||||
|
arr = [];
|
||||||
|
byDoc.set(v.documentId, arr);
|
||||||
|
}
|
||||||
|
arr.push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted.map((v) => {
|
||||||
|
const docVersions = byDoc.get(v.documentId)!;
|
||||||
|
const idx = docVersions.indexOf(v);
|
||||||
|
const prev = idx > 0 ? docVersions[idx - 1] : undefined;
|
||||||
|
const action = inferAction(v, prev);
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
action,
|
||||||
|
previousPath: action === "renamed" ? prev?.relativePath : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree(
|
||||||
|
documents: DocumentVersionWithoutContent[],
|
||||||
|
showDeleted: boolean
|
||||||
|
): TreeNode {
|
||||||
|
const root: TreeNode = {
|
||||||
|
name: "",
|
||||||
|
path: "",
|
||||||
|
isFolder: true,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const filtered = showDeleted
|
||||||
|
? documents
|
||||||
|
: documents.filter((d) => !d.isDeleted);
|
||||||
|
|
||||||
|
for (const doc of filtered) {
|
||||||
|
const parts = doc.relativePath.split("/");
|
||||||
|
let current = root;
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const isFile = i === parts.length - 1;
|
||||||
|
const path = parts.slice(0, i + 1).join("/");
|
||||||
|
|
||||||
|
if (isFile) {
|
||||||
|
current.children.push({
|
||||||
|
name: part,
|
||||||
|
path,
|
||||||
|
isFolder: false,
|
||||||
|
children: [],
|
||||||
|
document: doc,
|
||||||
|
isDeleted: doc.isDeleted
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let folder = current.children.find(
|
||||||
|
(c) => c.isFolder && c.name === part
|
||||||
|
);
|
||||||
|
if (!folder) {
|
||||||
|
folder = {
|
||||||
|
name: part,
|
||||||
|
path,
|
||||||
|
isFolder: true,
|
||||||
|
children: []
|
||||||
|
};
|
||||||
|
current.children.push(folder);
|
||||||
|
}
|
||||||
|
current = folder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortTree(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTree(node: TreeNode) {
|
||||||
|
node.children.sort((a, b) => {
|
||||||
|
if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.isFolder) sortTree(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 7) return `${days}d ago`;
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: days > 365 ? "numeric" : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteTime(dateStr: string): string {
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return "0 B";
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ["B", "KB", "MB", "GB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileExtension(path: string): string {
|
||||||
|
const dot = path.lastIndexOf(".");
|
||||||
|
return dot > -1 ? path.substring(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextFile(path: string): boolean {
|
||||||
|
const textExts = new Set([
|
||||||
|
"md",
|
||||||
|
"txt",
|
||||||
|
"json",
|
||||||
|
"yaml",
|
||||||
|
"yml",
|
||||||
|
"toml",
|
||||||
|
"xml",
|
||||||
|
"html",
|
||||||
|
"css",
|
||||||
|
"js",
|
||||||
|
"ts",
|
||||||
|
"svelte",
|
||||||
|
"rs",
|
||||||
|
"py",
|
||||||
|
"sh",
|
||||||
|
"bash",
|
||||||
|
"zsh",
|
||||||
|
"csv",
|
||||||
|
"svg",
|
||||||
|
"log",
|
||||||
|
"conf",
|
||||||
|
"cfg",
|
||||||
|
"ini",
|
||||||
|
"env",
|
||||||
|
"gitignore",
|
||||||
|
"editorconfig"
|
||||||
|
]);
|
||||||
|
return textExts.has(fileExtension(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageFile(path: string): boolean {
|
||||||
|
const imageExts = new Set([
|
||||||
|
"png",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"gif",
|
||||||
|
"webp",
|
||||||
|
"svg",
|
||||||
|
"ico",
|
||||||
|
"bmp"
|
||||||
|
]);
|
||||||
|
return imageExts.has(fileExtension(path));
|
||||||
|
}
|
||||||
22
frontend/history-ui/src/lib/view-types.ts
Normal file
22
frontend/history-ui/src/lib/view-types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent";
|
||||||
|
|
||||||
|
export type ActionType =
|
||||||
|
| "created"
|
||||||
|
| "updated"
|
||||||
|
| "renamed"
|
||||||
|
| "deleted"
|
||||||
|
| "restored";
|
||||||
|
|
||||||
|
export interface VersionEvent extends DocumentVersionWithoutContent {
|
||||||
|
action: ActionType;
|
||||||
|
previousPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
children: TreeNode[];
|
||||||
|
document?: DocumentVersionWithoutContent;
|
||||||
|
isDeleted?: boolean;
|
||||||
|
}
|
||||||
7
frontend/history-ui/src/main.ts
Normal file
7
frontend/history-ui/src/main.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { mount } from "svelte";
|
||||||
|
import App from "./App.svelte";
|
||||||
|
import "./app.css";
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById("app")! });
|
||||||
|
|
||||||
|
export default app;
|
||||||
5
frontend/history-ui/svelte.config.js
Normal file
5
frontend/history-ui/svelte.config.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess()
|
||||||
|
};
|
||||||
16
frontend/history-ui/tsconfig.json
Normal file
16
frontend/history-ui/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"types": ["svelte"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "src/**/*.svelte"]
|
||||||
|
}
|
||||||
15
frontend/history-ui/vite.config.ts
Normal file
15
frontend/history-ui/vite.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/vaults": "http://localhost:3010"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue