Files
codecraft/web/src/components/FilePreview.tsx
T
Richard ZinkandClaude Opus 5.5 5b569b6835 Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes
- 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>
2026-11-08 19:12:30 +01:00

142 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}
}
}