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}

+
+ ); +}