feat(evidence): inline preview for images and text/JSON files
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Adds a View button (eye icon) on each evidence card for previewable file types. Opens a full-screen modal: - Images (png/jpg/gif/webp/svg/…): rendered directly via <img> tag - JSON: fetched authenticated, pretty-printed in green mono - Text/log/md/csv/xml/yaml/…: fetched authenticated, shown in <pre> Non-previewable files only show the Download button as before. Modal closes on Escape or backdrop click. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,6 +63,21 @@ export async function getEvidence(evidenceId: string): Promise<EvidenceOut> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Content fetch (for inline preview) ────────────────────────────
|
||||||
|
|
||||||
|
/** Fetch raw file content for inline preview (text / JSON). */
|
||||||
|
export async function getEvidenceRawContent(
|
||||||
|
evidenceId: string,
|
||||||
|
): Promise<{ text: string; contentType: string }> {
|
||||||
|
const response = await client.get(`/evidence/${evidenceId}/file`, {
|
||||||
|
responseType: "blob",
|
||||||
|
});
|
||||||
|
const blob = response.data as Blob;
|
||||||
|
const text = await blob.text();
|
||||||
|
const contentType = (response.headers["content-type"] as string) || "";
|
||||||
|
return { text, contentType };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete ─────────────────────────────────────────────────────────
|
// ── Delete ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Delete an evidence record (only in editable states). */
|
/** Delete an evidence record (only in editable states). */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { FileIcon, Download, ExternalLink, Copy, Check } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { FileIcon, Download, Copy, Check, Eye } from "lucide-react";
|
||||||
|
import EvidencePreviewModal, { getPreviewType } from "./EvidencePreviewModal";
|
||||||
|
|
||||||
interface Evidence {
|
interface Evidence {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,16 +19,16 @@ interface EvidenceListProps {
|
|||||||
|
|
||||||
export default function EvidenceList({ evidences, onDownload }: EvidenceListProps) {
|
export default function EvidenceList({ evidences, onDownload }: EvidenceListProps) {
|
||||||
const [copiedHash, setCopiedHash] = useState<string | null>(null);
|
const [copiedHash, setCopiedHash] = useState<string | null>(null);
|
||||||
|
const [preview, setPreview] = useState<Evidence | null>(null);
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) =>
|
||||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const copyHash = async (hash: string) => {
|
const copyHash = async (hash: string) => {
|
||||||
await navigator.clipboard.writeText(hash);
|
await navigator.clipboard.writeText(hash);
|
||||||
@@ -45,8 +46,13 @@ export default function EvidenceList({ evidences, onDownload }: EvidenceListProp
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{evidences.map((evidence) => (
|
{evidences.map((evidence) => {
|
||||||
|
const previewType = getPreviewType(evidence.file_name);
|
||||||
|
const canPreview = previewType !== null;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={evidence.id}
|
key={evidence.id}
|
||||||
className="rounded-lg border border-gray-800 bg-gray-800/30 p-4 transition-colors hover:bg-gray-800/50"
|
className="rounded-lg border border-gray-800 bg-gray-800/30 p-4 transition-colors hover:bg-gray-800/50"
|
||||||
@@ -63,6 +69,19 @@ export default function EvidenceList({ evidences, onDownload }: EvidenceListProp
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canPreview && (
|
||||||
|
<button
|
||||||
|
onClick={() => setPreview(evidence)}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||||
|
title="Preview"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onDownload(evidence.id)}
|
onClick={() => onDownload(evidence.id)}
|
||||||
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||||
@@ -71,6 +90,7 @@ export default function EvidenceList({ evidences, onDownload }: EvidenceListProp
|
|||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* SHA256 Hash */}
|
{/* SHA256 Hash */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
@@ -91,7 +111,24 @@ export default function EvidenceList({ evidences, onDownload }: EvidenceListProp
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Preview modal */}
|
||||||
|
{preview && (
|
||||||
|
<EvidencePreviewModal
|
||||||
|
evidenceId={preview.id}
|
||||||
|
fileName={preview.file_name}
|
||||||
|
previewType={getPreviewType(preview.file_name)}
|
||||||
|
downloadUrl={preview.download_url ?? `/api/v1/evidence/${preview.id}/file`}
|
||||||
|
onClose={() => setPreview(null)}
|
||||||
|
onDownload={() => {
|
||||||
|
onDownload(preview.id);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
173
frontend/src/components/EvidencePreviewModal.tsx
Normal file
173
frontend/src/components/EvidencePreviewModal.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* EvidencePreviewModal
|
||||||
|
*
|
||||||
|
* Shows evidence files inline:
|
||||||
|
* - Images → <img> rendered directly (same-origin cookie sent automatically)
|
||||||
|
* - JSON → fetched via authenticated Axios, pretty-printed
|
||||||
|
* - Text → fetched via authenticated Axios, shown in <pre>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { X, Loader2, AlertCircle, Download } from "lucide-react";
|
||||||
|
import { getEvidenceRawContent } from "../api/evidence";
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PreviewType = "image" | "json" | "text" | null;
|
||||||
|
|
||||||
|
const IMAGE_EXTS = new Set([
|
||||||
|
"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "tiff", "tif",
|
||||||
|
]);
|
||||||
|
const TEXT_EXTS = new Set([
|
||||||
|
"txt", "log", "md", "csv", "xml", "html", "htm", "yaml", "yml",
|
||||||
|
"ini", "cfg", "conf", "sh", "bat", "ps1", "py", "js", "ts",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getPreviewType(fileName: string): PreviewType {
|
||||||
|
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (IMAGE_EXTS.has(ext)) return "image";
|
||||||
|
if (ext === "json") return "json";
|
||||||
|
if (TEXT_EXTS.has(ext)) return "text";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
evidenceId: string;
|
||||||
|
fileName: string;
|
||||||
|
previewType: PreviewType;
|
||||||
|
downloadUrl: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EvidencePreviewModal({
|
||||||
|
evidenceId,
|
||||||
|
fileName,
|
||||||
|
previewType,
|
||||||
|
downloadUrl,
|
||||||
|
onClose,
|
||||||
|
onDownload,
|
||||||
|
}: Props) {
|
||||||
|
const [text, setText] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch text / JSON content on mount (not needed for images)
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewType === "text" || previewType === "json") {
|
||||||
|
setLoading(true);
|
||||||
|
getEvidenceRawContent(evidenceId)
|
||||||
|
.then(({ text: raw }) => {
|
||||||
|
if (previewType === "json") {
|
||||||
|
try {
|
||||||
|
setText(JSON.stringify(JSON.parse(raw), null, 2));
|
||||||
|
} catch {
|
||||||
|
setText(raw); // not valid JSON — show raw
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setText(raw);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e?.message ?? "Failed to load file"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [evidenceId, previewType]);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
const handleKey = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener("keydown", handleKey);
|
||||||
|
return () => document.removeEventListener("keydown", handleKey);
|
||||||
|
}, [handleKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
/* backdrop */
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
>
|
||||||
|
{/* panel */}
|
||||||
|
<div className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-3">
|
||||||
|
<span className="truncate text-sm font-medium text-gray-200">
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDownload}
|
||||||
|
title="Download"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||||
|
title="Close (Esc)"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* body */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{/* ── IMAGE ─────────────────────────────────────── */}
|
||||||
|
{previewType === "image" && (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={downloadUrl}
|
||||||
|
alt={fileName}
|
||||||
|
className="max-h-[75vh] max-w-full rounded-lg object-contain"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||||
|
setError("Image could not be loaded");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{error && <ErrorMessage message={error} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── TEXT / JSON ────────────────────────────────── */}
|
||||||
|
{(previewType === "text" || previewType === "json") && (
|
||||||
|
<>
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !loading && <ErrorMessage message={error} />}
|
||||||
|
{text !== null && !loading && (
|
||||||
|
<pre
|
||||||
|
className={`whitespace-pre-wrap break-words rounded-lg bg-gray-950 p-4 font-mono text-sm text-gray-300 ${
|
||||||
|
previewType === "json" ? "text-emerald-300" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorMessage({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-12 text-center">
|
||||||
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
||||||
|
<p className="text-sm text-red-400">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user