ai
This commit is contained in:
parent
8f2f5e4fa9
commit
a20264bcaf
112 changed files with 13567 additions and 3694 deletions
109
frontend/history-ui/src/lib/api.ts
Normal file
109
frontend/history-ui/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type {
|
||||
DocumentVersion,
|
||||
DocumentVersionWithoutContent,
|
||||
FetchLatestDocumentsResponse,
|
||||
PingResponse,
|
||||
VaultHistoryResponse
|
||||
} from "./types";
|
||||
|
||||
export class ApiClient {
|
||||
constructor(
|
||||
private vaultId: string,
|
||||
private token: string
|
||||
) {}
|
||||
|
||||
private get baseUrl(): string {
|
||||
return `/vaults/${encodeURIComponent(this.vaultId)}`;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"device-id": "history-ui"
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchJson<T>(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...this.headers(), ...init?.headers }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${body}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
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: this.headers() }
|
||||
);
|
||||
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}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
async restoreVersion(
|
||||
documentId: string,
|
||||
vaultUpdateId: number
|
||||
): Promise<DocumentVersionWithoutContent> {
|
||||
return this.fetchJson(
|
||||
`${this.baseUrl}/documents/${documentId}/restore`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ vaultUpdateId })
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
291
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
291
frontend/history-ui/src/lib/stores.svelte.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import { ApiClient } from "./api";
|
||||
import type {
|
||||
DocumentVersionWithoutContent,
|
||||
VersionEvent,
|
||||
ActionType,
|
||||
TreeNode
|
||||
} from "./types";
|
||||
|
||||
class AuthStore {
|
||||
vaultId = $state("");
|
||||
token = $state("");
|
||||
isAuthenticated = $state(false);
|
||||
serverVersion = $state("");
|
||||
api = $state<ApiClient | null>(null);
|
||||
|
||||
login(vaultId: string, token: string, serverVersion: string) {
|
||||
this.vaultId = vaultId;
|
||||
this.token = token;
|
||||
this.serverVersion = serverVersion;
|
||||
this.isAuthenticated = true;
|
||||
this.api = new ApiClient(vaultId, token);
|
||||
sessionStorage.setItem(
|
||||
"vaultlink_auth",
|
||||
JSON.stringify({ vaultId, token })
|
||||
);
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.vaultId = "";
|
||||
this.token = "";
|
||||
this.isAuthenticated = false;
|
||||
this.serverVersion = "";
|
||||
this.api = null;
|
||||
sessionStorage.removeItem("vaultlink_auth");
|
||||
}
|
||||
|
||||
tryRestore(): { vaultId: string; token: string } | null {
|
||||
const stored = sessionStorage.getItem("vaultlink_auth");
|
||||
if (!stored) return null;
|
||||
try {
|
||||
return JSON.parse(stored) as {
|
||||
vaultId: string;
|
||||
token: string;
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
export 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));
|
||||
}
|
||||
54
frontend/history-ui/src/lib/types.ts
Normal file
54
frontend/history-ui/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export interface DocumentVersionWithoutContent {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
export interface DocumentVersion {
|
||||
vaultUpdateId: number;
|
||||
documentId: string;
|
||||
relativePath: string;
|
||||
updatedDate: string;
|
||||
contentBase64: string;
|
||||
isDeleted: boolean;
|
||||
userId: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
export interface FetchLatestDocumentsResponse {
|
||||
latestDocuments: DocumentVersionWithoutContent[];
|
||||
lastUpdateId: number;
|
||||
}
|
||||
|
||||
export interface VaultHistoryResponse {
|
||||
versions: DocumentVersionWithoutContent[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface PingResponse {
|
||||
serverVersion: string;
|
||||
isAuthenticated: boolean;
|
||||
mergeableFileExtensions: string[];
|
||||
supportedApiVersion: number;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue