From 5a070340f1252d80961abade41695ce84940850a Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Fri, 8 May 2026 21:37:37 +0100 Subject: [PATCH] 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.) --- frontend/history-ui/index.html | 13 + frontend/history-ui/package.json | 16 + frontend/history-ui/src/App.svelte | 78 ++ frontend/history-ui/src/app.css | 101 +++ .../src/components/ActivityFeed.svelte | 346 +++++++++ .../src/components/ConfirmDialog.svelte | 167 ++++ .../src/components/Dashboard.svelte | 508 ++++++++++++ .../history-ui/src/components/DiffView.svelte | 288 +++++++ .../src/components/DocumentDetail.svelte | 729 ++++++++++++++++++ .../history-ui/src/components/FileTree.svelte | 124 +++ .../history-ui/src/components/Header.svelte | 144 ++++ .../history-ui/src/components/Login.svelte | 176 +++++ .../src/components/TimeSlider.svelte | 191 +++++ .../src/components/ToastContainer.svelte | 80 ++ .../src/components/VaultPicker.svelte | 198 +++++ frontend/history-ui/src/lib/api.ts | 146 ++++ frontend/history-ui/src/lib/stores.svelte.ts | 290 +++++++ frontend/history-ui/src/lib/view-types.ts | 22 + frontend/history-ui/src/main.ts | 7 + frontend/history-ui/svelte.config.js | 5 + frontend/history-ui/tsconfig.json | 16 + frontend/history-ui/vite.config.ts | 15 + 22 files changed, 3660 insertions(+) create mode 100644 frontend/history-ui/index.html create mode 100644 frontend/history-ui/package.json create mode 100644 frontend/history-ui/src/App.svelte create mode 100644 frontend/history-ui/src/app.css create mode 100644 frontend/history-ui/src/components/ActivityFeed.svelte create mode 100644 frontend/history-ui/src/components/ConfirmDialog.svelte create mode 100644 frontend/history-ui/src/components/Dashboard.svelte create mode 100644 frontend/history-ui/src/components/DiffView.svelte create mode 100644 frontend/history-ui/src/components/DocumentDetail.svelte create mode 100644 frontend/history-ui/src/components/FileTree.svelte create mode 100644 frontend/history-ui/src/components/Header.svelte create mode 100644 frontend/history-ui/src/components/Login.svelte create mode 100644 frontend/history-ui/src/components/TimeSlider.svelte create mode 100644 frontend/history-ui/src/components/ToastContainer.svelte create mode 100644 frontend/history-ui/src/components/VaultPicker.svelte create mode 100644 frontend/history-ui/src/lib/api.ts create mode 100644 frontend/history-ui/src/lib/stores.svelte.ts create mode 100644 frontend/history-ui/src/lib/view-types.ts create mode 100644 frontend/history-ui/src/main.ts create mode 100644 frontend/history-ui/svelte.config.js create mode 100644 frontend/history-ui/tsconfig.json create mode 100644 frontend/history-ui/vite.config.ts diff --git a/frontend/history-ui/index.html b/frontend/history-ui/index.html new file mode 100644 index 00000000..cde20e90 --- /dev/null +++ b/frontend/history-ui/index.html @@ -0,0 +1,13 @@ + + + + + + VaultLink2 + + + +
+ + + diff --git a/frontend/history-ui/package.json b/frontend/history-ui/package.json new file mode 100644 index 00000000..000dbdb0 --- /dev/null +++ b/frontend/history-ui/package.json @@ -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" + } +} diff --git a/frontend/history-ui/src/App.svelte b/frontend/history-ui/src/App.svelte new file mode 100644 index 00000000..37b4cd34 --- /dev/null +++ b/frontend/history-ui/src/App.svelte @@ -0,0 +1,78 @@ + + +{#if restoring} +
+
+
+{:else if !auth.token} + +{:else if !auth.isAuthenticated} + +{:else} + +{/if} + + + + diff --git a/frontend/history-ui/src/app.css b/frontend/history-ui/src/app.css new file mode 100644 index 00000000..ff3e6a9c --- /dev/null +++ b/frontend/history-ui/src/app.css @@ -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); +} diff --git a/frontend/history-ui/src/components/ActivityFeed.svelte b/frontend/history-ui/src/components/ActivityFeed.svelte new file mode 100644 index 00000000..b20991e2 --- /dev/null +++ b/frontend/history-ui/src/components/ActivityFeed.svelte @@ -0,0 +1,346 @@ + + +
+ {#if loading && versions.length === 0} +
Loading activity...
+ {:else if versions.length === 0} +
+ No activity yet. Documents will appear here as sync clients + make changes. +
+ {:else} + {#each grouped as group} +
+
{group.date}
+
+ {#each group.items as event} +
+ + +
+ {/each} +
+
+ {/each} + + {#if hasMore} +
+ +
+ {/if} + {/if} +
+ + diff --git a/frontend/history-ui/src/components/ConfirmDialog.svelte b/frontend/history-ui/src/components/ConfirmDialog.svelte new file mode 100644 index 00000000..e91f790a --- /dev/null +++ b/frontend/history-ui/src/components/ConfirmDialog.svelte @@ -0,0 +1,167 @@ + + + + + + + + diff --git a/frontend/history-ui/src/components/Dashboard.svelte b/frontend/history-ui/src/components/Dashboard.svelte new file mode 100644 index 00000000..8cf89677 --- /dev/null +++ b/frontend/history-ui/src/components/Dashboard.svelte @@ -0,0 +1,508 @@ + + +
+
+ +
+ + + + +
+ {#if maxUpdateId > 0} +
+ { + timeSliderValue = v; + }} + /> +
+ {/if} + + {#if selectedDocumentId} + nav.goHome()} + onRestore={handleRefresh} + /> + {:else} +
+ + +
+ + {#if activeTab === "activity"} + { + timeSliderValue = id >= maxUpdateId ? null : id; + }} + /> + {:else} +
+ {#each latestDocuments + .filter((d) => showDeleted || !d.isDeleted) + .sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc} + + {/each} +
+ {/if} + {/if} +
+
+
+ + diff --git a/frontend/history-ui/src/components/DiffView.svelte b/frontend/history-ui/src/components/DiffView.svelte new file mode 100644 index 00000000..be97952c --- /dev/null +++ b/frontend/history-ui/src/components/DiffView.svelte @@ -0,0 +1,288 @@ + + +
+
+ {oldLabel} + + {newLabel} + + +{stats.added} + -{stats.removed} + +
+
+ {#each diffLines as line} +
+ + {line.oldLineNo ?? ""} + + + {line.newLineNo ?? ""} + + + {#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if} + + {line.content} +
+ {/each} +
+
+ + diff --git a/frontend/history-ui/src/components/DocumentDetail.svelte b/frontend/history-ui/src/components/DocumentDetail.svelte new file mode 100644 index 00000000..e4de2de8 --- /dev/null +++ b/frontend/history-ui/src/components/DocumentDetail.svelte @@ -0,0 +1,729 @@ + + +
+ +
+ +
+
+ + {currentPath} + + {#if isDeleted} + Deleted + {:else} + Active + {/if} +
+
+ + {documentId.substring(0, 8)}... + + {#if latest} + · + {versions.length} version{versions.length !== 1 ? "s" : ""} + · + Last by {latest.userId} + {/if} +
+
+
+ + {#if loading} +
Loading versions...
+ {:else} + +
+
+ {#if selectedVersion} +
+ + +
+ + Viewing v#{selectedVersion.vaultUpdateId} + · + {relativeTime(selectedVersion.updatedDate)} + +
+ +
+ {#if loadingContent} +
Loading content...
+ {:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null} + + {:else if activeTab === "preview"} + {#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""} +
{loadedContent ?? ""}
+ {:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes} +
+ {selectedVersion.relativePath} +
+ {:else} +
+
๐Ÿ“ฆ
+
Binary file
+
+ {formatBytes(selectedVersion.contentSize)} +
+
+ {/if} + {/if} +
+ {/if} +
+ + +
+
Version History
+
+ {#each [...versionEvents].reverse() as event, i} + {@const v = event.version} + {@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId} +
+ + {#if event.previousPath} +
+ {event.previousPath} → {v.relativePath} +
+ {/if} +
+ {#if i < versionEvents.length - 1} + + {/if} + {#if v !== latest} + + {/if} +
+
+ {/each} +
+
+
+ {/if} +
+ +{#if showRestoreDialog && restoreTarget} + { + showRestoreDialog = false; + restoreTarget = null; + }} + /> +{/if} + + diff --git a/frontend/history-ui/src/components/FileTree.svelte b/frontend/history-ui/src/components/FileTree.svelte new file mode 100644 index 00000000..a1a99d4c --- /dev/null +++ b/frontend/history-ui/src/components/FileTree.svelte @@ -0,0 +1,124 @@ + + +{#if node.isFolder && depth === 0} + {#each node.children as child} + + {/each} +{:else if node.isFolder} +
+ + {#if isExpanded(node.path)} + {#each node.children as child} + + {/each} + {/if} +
+{:else} + +{/if} + + diff --git a/frontend/history-ui/src/components/Header.svelte b/frontend/history-ui/src/components/Header.svelte new file mode 100644 index 00000000..8e635224 --- /dev/null +++ b/frontend/history-ui/src/components/Header.svelte @@ -0,0 +1,144 @@ + + +
+
+ + + + + + VaultLink + / + {vaultId} +
+ +
+ v{serverVersion} + + {#if auth.availableVaults.length > 1} + + {/if} + +
+
+ + diff --git a/frontend/history-ui/src/components/Login.svelte b/frontend/history-ui/src/components/Login.svelte new file mode 100644 index 00000000..8d331966 --- /dev/null +++ b/frontend/history-ui/src/components/Login.svelte @@ -0,0 +1,176 @@ + + + + + diff --git a/frontend/history-ui/src/components/TimeSlider.svelte b/frontend/history-ui/src/components/TimeSlider.svelte new file mode 100644 index 00000000..0bdc3abf --- /dev/null +++ b/frontend/history-ui/src/components/TimeSlider.svelte @@ -0,0 +1,191 @@ + + +
+
+ + + + + Time Travel +
+ +
+ +
+ +
+ {#if isNow} + Now + {:else if currentVersion} + + #{value} + · + {relativeTime(currentVersion.updatedDate)} + + {:else} + #{value} + {/if} +
+ + {#if !isNow} + + {/if} +
+ + diff --git a/frontend/history-ui/src/components/ToastContainer.svelte b/frontend/history-ui/src/components/ToastContainer.svelte new file mode 100644 index 00000000..39ab1705 --- /dev/null +++ b/frontend/history-ui/src/components/ToastContainer.svelte @@ -0,0 +1,80 @@ + + +{#if toasts.items.length > 0} +
+ {#each toasts.items as toast (toast.id)} +
+ {toast.message} + +
+ {/each} +
+{/if} + + diff --git a/frontend/history-ui/src/components/VaultPicker.svelte b/frontend/history-ui/src/components/VaultPicker.svelte new file mode 100644 index 00000000..8ca82737 --- /dev/null +++ b/frontend/history-ui/src/components/VaultPicker.svelte @@ -0,0 +1,198 @@ + + +
+
+
+ +
+ + {#if auth.availableVaults.length === 0} +
+

No vaults found

+

+ Vaults are created when a sync client first connects. +

+
+ {:else} +
    + {#each auth.availableVaults as vault} +
  • + +
  • + {/each} +
+ {/if} +
+
+ + diff --git a/frontend/history-ui/src/lib/api.ts b/frontend/history-ui/src/lib/api.ts new file mode 100644 index 00000000..eefc594d --- /dev/null +++ b/frontend/history-ui/src/lib/api.ts @@ -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( + path: string, + token: string, + init?: RequestInit +): Promise { + 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; +} + +export async function listVaults(token: string): Promise { + 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(path: string, init?: RequestInit): Promise { + return fetchJsonWithToken(path, this.token, init); + } + + async ping(): Promise { + return this.fetchJson(`${this.baseUrl}/ping`); + } + + async fetchLatestDocuments(): Promise { + return this.fetchJson(`${this.baseUrl}/documents`); + } + + async fetchDocumentVersions( + documentId: string + ): Promise { + return this.fetchJson( + `${this.baseUrl}/documents/${documentId}/versions` + ); + } + + async fetchDocumentVersion( + documentId: string, + vaultUpdateId: number + ): Promise { + return this.fetchJson( + `${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}` + ); + } + + async fetchDocumentVersionContent( + documentId: string, + vaultUpdateId: number + ): Promise { + 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 { + 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 { + 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 { + 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 + }); + } +} diff --git a/frontend/history-ui/src/lib/stores.svelte.ts b/frontend/history-ui/src/lib/stores.svelte.ts new file mode 100644 index 00000000..16ee4a30 --- /dev/null +++ b/frontend/history-ui/src/lib/stores.svelte.ts @@ -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([]); + isAuthenticated = $state(false); + api = $state(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({ 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([]); + 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(); + 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)); +} diff --git a/frontend/history-ui/src/lib/view-types.ts b/frontend/history-ui/src/lib/view-types.ts new file mode 100644 index 00000000..8b8cb0ae --- /dev/null +++ b/frontend/history-ui/src/lib/view-types.ts @@ -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; +} diff --git a/frontend/history-ui/src/main.ts b/frontend/history-ui/src/main.ts new file mode 100644 index 00000000..c72cabd0 --- /dev/null +++ b/frontend/history-ui/src/main.ts @@ -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; diff --git a/frontend/history-ui/svelte.config.js b/frontend/history-ui/svelte.config.js new file mode 100644 index 00000000..76a68bfc --- /dev/null +++ b/frontend/history-ui/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess() +}; diff --git a/frontend/history-ui/tsconfig.json b/frontend/history-ui/tsconfig.json new file mode 100644 index 00000000..216dc140 --- /dev/null +++ b/frontend/history-ui/tsconfig.json @@ -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"] +} diff --git a/frontend/history-ui/vite.config.ts b/frontend/history-ui/vite.config.ts new file mode 100644 index 00000000..18f6be82 --- /dev/null +++ b/frontend/history-ui/vite.config.ts @@ -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" + } + } +});