From 986e91a88a9181646585aa6e6dae27d2d39dfceb Mon Sep 17 00:00:00 2001 From: kitos Date: Thu, 28 May 2026 13:49:35 +0200 Subject: [PATCH] feat(evidence): inline preview for images and text/JSON files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 tag - JSON: fetched authenticated, pretty-printed in green mono - Text/log/md/csv/xml/yaml/…: fetched authenticated, shown in

Non-previewable files only show the Download button as before.
Modal closes on Escape or backdrop click.

Co-Authored-By: Claude Sonnet 4.6 
---
 frontend/src/api/evidence.ts                  |  15 ++
 frontend/src/components/EvidenceList.tsx      | 133 +++++++++-----
 .../src/components/EvidencePreviewModal.tsx   | 173 ++++++++++++++++++
 3 files changed, 273 insertions(+), 48 deletions(-)
 create mode 100644 frontend/src/components/EvidencePreviewModal.tsx

diff --git a/frontend/src/api/evidence.ts b/frontend/src/api/evidence.ts
index c4c75ce..8146ee5 100644
--- a/frontend/src/api/evidence.ts
+++ b/frontend/src/api/evidence.ts
@@ -63,6 +63,21 @@ export async function getEvidence(evidenceId: string): Promise {
   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 an evidence record (only in editable states). */
diff --git a/frontend/src/components/EvidenceList.tsx b/frontend/src/components/EvidenceList.tsx
index ba484b9..23bd6a4 100644
--- a/frontend/src/components/EvidenceList.tsx
+++ b/frontend/src/components/EvidenceList.tsx
@@ -1,5 +1,6 @@
-import { FileIcon, Download, ExternalLink, Copy, Check } from "lucide-react";
 import { useState } from "react";
+import { FileIcon, Download, Copy, Check, Eye } from "lucide-react";
+import EvidencePreviewModal, { getPreviewType } from "./EvidencePreviewModal";
 
 interface Evidence {
   id: string;
@@ -18,16 +19,16 @@ interface EvidenceListProps {
 
 export default function EvidenceList({ evidences, onDownload }: EvidenceListProps) {
   const [copiedHash, setCopiedHash] = useState(null);
+  const [preview, setPreview] = useState(null);
 
-  const formatDate = (dateStr: string) => {
-    return new Date(dateStr).toLocaleDateString("en-US", {
+  const formatDate = (dateStr: string) =>
+    new Date(dateStr).toLocaleDateString("en-US", {
       year: "numeric",
       month: "short",
       day: "numeric",
       hour: "2-digit",
       minute: "2-digit",
     });
-  };
 
   const copyHash = async (hash: string) => {
     await navigator.clipboard.writeText(hash);
@@ -45,53 +46,89 @@ export default function EvidenceList({ evidences, onDownload }: EvidenceListProp
   }
 
   return (
-    
- {evidences.map((evidence) => ( -
-
-
-
- + <> +
+ {evidences.map((evidence) => { + const previewType = getPreviewType(evidence.file_name); + const canPreview = previewType !== null; + + return ( +
+
+
+
+ +
+
+

{evidence.file_name}

+

+ Uploaded {formatDate(evidence.uploaded_at)} +

+
+
+ + {/* Action buttons */} +
+ {canPreview && ( + + )} + +
-
-

{evidence.file_name}

-

- Uploaded {formatDate(evidence.uploaded_at)} -

+ + {/* SHA256 Hash */} +
+ SHA256: + + {evidence.sha256_hash} + +
- -
+ ); + })} +
- {/* SHA256 Hash */} -
- SHA256: - - {evidence.sha256_hash} - - -
-
- ))} -
+ {/* Preview modal */} + {preview && ( + setPreview(null)} + onDownload={() => { + onDownload(preview.id); + setPreview(null); + }} + /> + )} + ); } diff --git a/frontend/src/components/EvidencePreviewModal.tsx b/frontend/src/components/EvidencePreviewModal.tsx new file mode 100644 index 0000000..a10add9 --- /dev/null +++ b/frontend/src/components/EvidencePreviewModal.tsx @@ -0,0 +1,173 @@ +/** + * EvidencePreviewModal + * + * Shows evidence files inline: + * - Images → rendered directly (same-origin cookie sent automatically) + * - JSON → fetched via authenticated Axios, pretty-printed + * - Text → fetched via authenticated Axios, shown in
+ */
+
+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(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState(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 */
+    
e.target === e.currentTarget && onClose()} + > + {/* panel */} +
+ {/* header */} +
+ + {fileName} + +
+ + +
+
+ + {/* body */} +
+ {/* ── IMAGE ─────────────────────────────────────── */} + {previewType === "image" && ( +
+ {fileName} { + (e.currentTarget as HTMLImageElement).style.display = "none"; + setError("Image could not be loaded"); + }} + /> + {error && } +
+ )} + + {/* ── TEXT / JSON ────────────────────────────────── */} + {(previewType === "text" || previewType === "json") && ( + <> + {loading && ( +
+ +
+ )} + {error && !loading && } + {text !== null && !loading && ( +
+                  {text}
+                
+ )} + + )} +
+
+
+ ); +} + +function ErrorMessage({ message }: { message: string }) { + return ( +
+ +

{message}

+
+ ); +}