- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
142 lines
4.6 KiB
TypeScript
142 lines
4.6 KiB
TypeScript
import { lazy, Suspense, useEffect, useState } from "react";
|
||
import { get } from "../api";
|
||
import { useT } from "../i18n";
|
||
import { Spinner } from "./Activity";
|
||
|
||
const ModelViewer = lazy(() => import("./ModelViewer").then((m) => ({ default: m.ModelViewer })));
|
||
|
||
export type PreviewKind = "image" | "pdf" | "doc" | "audio" | "module" | "model";
|
||
|
||
const KINDS: [RegExp, PreviewKind][] = [
|
||
[/\.(png|jpe?g|gif|webp|bmp|svg)$/i, "image"],
|
||
[/\.pdf$/i, "pdf"],
|
||
[/\.(docx|pptx|xlsx|xlsm|xls|ods)$/i, "doc"],
|
||
[/\.(wav|mp3|ogg|oga|flac|m4a)$/i, "audio"],
|
||
[/\.(xm|mod|s3m|it)$/i, "module"],
|
||
[/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i, "model"],
|
||
];
|
||
|
||
export function previewKind(path: string): PreviewKind | undefined {
|
||
return KINDS.find(([re]) => re.test(path))?.[1];
|
||
}
|
||
|
||
const q = (p: string) => `path=${encodeURIComponent(p)}`;
|
||
|
||
function PdfPreview({ path }: { path: string }) {
|
||
const t = useT();
|
||
const [pages, setPages] = useState<number>();
|
||
const [page, setPage] = useState(1);
|
||
useEffect(() => {
|
||
setPage(1);
|
||
get<{ pages: number }>(`/files/preview/pdf-info?${q(path)}`).then((r) => setPages(r.pages)).catch(() => setPages(1));
|
||
}, [path]);
|
||
return (
|
||
<div className="pdf-preview">
|
||
<div className="row pdf-nav">
|
||
<button className="small" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||
‹
|
||
</button>
|
||
<span>{t("preview.page", { n: page, total: pages ?? "…" })}</span>
|
||
<button className="small" disabled={!pages || page >= pages} onClick={() => setPage(page + 1)}>
|
||
›
|
||
</button>
|
||
<span className="spacer" />
|
||
<a className="button small" href={`/api/files/raw?${q(path)}`} target="_blank" rel="noreferrer">
|
||
{t("preview.openBrowser")}
|
||
</a>
|
||
</div>
|
||
<div className="pane-scroll image-preview">
|
||
<img key={page} src={`/api/files/preview/pdf-page?${q(path)}&page=${page}`} alt={`page ${page}`} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface DocData {
|
||
kind: "text" | "table";
|
||
text?: string;
|
||
sheets?: { name: string; rows: string[][]; total_rows: number }[];
|
||
}
|
||
|
||
function DocPreview({ path }: { path: string }) {
|
||
const t = useT();
|
||
const [data, setData] = useState<DocData>();
|
||
const [error, setError] = useState<string>();
|
||
const [sheet, setSheet] = useState(0);
|
||
useEffect(() => {
|
||
setData(undefined);
|
||
setSheet(0);
|
||
get<DocData>(`/files/preview/doc?${q(path)}`).then(setData).catch((e) => setError(e.message));
|
||
}, [path]);
|
||
if (error) return <div className="notice err">{error}</div>;
|
||
if (!data) return <div className="empty"><Spinner /></div>;
|
||
if (data.kind === "text") return <pre className="pane-scroll doc-text">{data.text}</pre>;
|
||
const s = data.sheets?.[sheet];
|
||
return (
|
||
<div className="pdf-preview">
|
||
<div className="mode-tabs sheet-tabs">
|
||
{data.sheets?.map((x, i) => (
|
||
<button key={x.name} className={i === sheet ? "active" : ""} onClick={() => setSheet(i)}>
|
||
{x.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="pane-scroll">
|
||
{s && (
|
||
<table className="grid sheet">
|
||
<tbody>
|
||
{s.rows.map((r, i) => (
|
||
<tr key={i}>
|
||
<th className="num">{i + 1}</th>
|
||
{r.map((c, j) => (
|
||
<td key={j}>{c}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{s && s.total_rows > s.rows.length && <div className="empty">{t("preview.moreRows", { n: s.total_rows - s.rows.length })}</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function FilePreview({ path, kind }: { path: string; kind: PreviewKind }) {
|
||
const t = useT();
|
||
const raw = `/api/files/raw?${q(path)}`;
|
||
switch (kind) {
|
||
case "image":
|
||
return (
|
||
<div className="pane-scroll image-preview">
|
||
<img src={raw} alt="" />
|
||
</div>
|
||
);
|
||
case "pdf":
|
||
return <PdfPreview path={path} />;
|
||
case "doc":
|
||
return <DocPreview path={path} />;
|
||
case "audio":
|
||
return (
|
||
<div className="audio-preview">
|
||
<audio controls src={raw} preload="metadata" />
|
||
</div>
|
||
);
|
||
case "module":
|
||
return (
|
||
<div className="audio-preview">
|
||
<div className="muted">{t("preview.moduleHint")}</div>
|
||
<audio controls src={`/api/files/preview/module?${q(path)}`} preload="none" />
|
||
</div>
|
||
);
|
||
case "model": {
|
||
const ext = path.split(".").pop()!.toLowerCase();
|
||
return (
|
||
<Suspense fallback={<div className="empty"><Spinner /></div>}>
|
||
<ModelViewer url={raw} ext={ext} />
|
||
</Suspense>
|
||
);
|
||
}
|
||
}
|
||
}
|