feat(rt-import): add Image to Base64 converter utility
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

New drag-and-drop section at the bottom of the Import RT page so operators
can convert screenshots to base64 without leaving the page. Includes
thumbnail preview, copy-base64 and copy-JSON-snippet buttons with
2s feedback, per-image delete and clear-all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-06-05 13:08:55 +02:00
parent 14a56a6057
commit 421b786953

View File

@@ -1,4 +1,4 @@
import { useState, useRef } from "react"; import { useState, useRef, useCallback } from "react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
Upload, Upload,
@@ -10,6 +10,11 @@ import {
Loader2, Loader2,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
ImageIcon,
Copy,
Check,
Trash2,
Braces,
} from "lucide-react"; } from "lucide-react";
import { importRT, type RTImportPayload, type RTTechniqueEntry, type RTEvidenceEntry } from "../api/tests"; import { importRT, type RTImportPayload, type RTTechniqueEntry, type RTEvidenceEntry } from "../api/tests";
@@ -438,6 +443,188 @@ export default function ImportRTPage() {
{(importMutation.error as Error)?.message || "Import failed"} {(importMutation.error as Error)?.message || "Import failed"}
</div> </div>
)} )}
{/* ── Base64 Image Converter ─────────────────────────────────── */}
<Base64Converter />
</div>
);
}
/* ── Base64 Converter Component ────────────────────────────────────── */
interface ConvertedImage {
id: string;
filename: string;
dataUrl: string; // data:image/xxx;base64,... — used for <img> preview
b64: string; // raw base64 data — used in the JSON evidence field
sizeKb: number;
}
function Base64Converter() {
const [images, setImages] = useState<ConvertedImage[]>([]);
const [dragging, setDragging] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const processFiles = useCallback((files: FileList | File[]) => {
const fileArr = Array.from(files);
const allowed = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"];
fileArr.forEach((file) => {
if (!allowed.includes(file.type)) return;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target?.result as string;
// dataUrl = "data:image/png;base64,iVBORw..."
const b64 = dataUrl.split(",")[1] ?? "";
setImages((prev) => [
...prev,
{
id: crypto.randomUUID(),
filename: file.name,
dataUrl,
b64,
sizeKb: Math.round(file.size / 1024),
},
]);
};
reader.readAsDataURL(file);
});
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length) processFiles(e.dataTransfer.files);
},
[processFiles],
);
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
});
};
const jsonSnippet = (img: ConvertedImage) =>
JSON.stringify({ filename: img.filename, data: img.b64, caption: "" }, null, 2);
return (
<div className="rounded-xl border border-purple-500/20 bg-gray-900 p-5 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="rounded-lg bg-purple-500/10 p-1.5">
<ImageIcon className="h-4 w-4 text-purple-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Image Base64 Converter</h2>
<p className="text-xs text-gray-500">
Drop screenshots here to get their base64 paste directly into your JSON
</p>
</div>
</div>
{images.length > 0 && (
<button
onClick={() => setImages([])}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 px-2.5 py-1.5 text-xs text-gray-400 hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<Trash2 className="h-3 w-3" />
Clear all
</button>
)}
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed py-8 transition-colors ${
dragging
? "border-purple-400 bg-purple-500/10"
: "border-gray-700 hover:border-purple-500/50 hover:bg-purple-500/5"
}`}
>
<Upload className={`h-6 w-6 transition-colors ${dragging ? "text-purple-400" : "text-gray-600"}`} />
<p className="text-sm text-gray-500">
<span className="font-medium text-purple-400">Click or drag</span> images here
</p>
<p className="text-xs text-gray-600">PNG · JPG · GIF · WebP · BMP</p>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/bmp"
multiple
className="hidden"
onChange={(e) => { if (e.target.files) { processFiles(e.target.files); e.target.value = ""; } }}
/>
</div>
{/* Converted images */}
{images.length > 0 && (
<div className="space-y-3">
{images.map((img) => (
<div key={img.id} className="flex gap-3 rounded-xl border border-gray-800 bg-gray-800/40 p-3">
{/* Thumbnail */}
<img
src={img.dataUrl}
alt={img.filename}
className="h-20 w-20 shrink-0 rounded-lg border border-gray-700 object-cover"
/>
{/* Info + actions */}
<div className="flex flex-1 flex-col gap-2 min-w-0">
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-medium text-gray-200">{img.filename}</p>
<span className="shrink-0 text-xs text-gray-500">{img.sizeKb} KB</span>
</div>
{/* base64 preview (truncated) */}
<div className="rounded-lg border border-gray-700 bg-gray-900 px-3 py-1.5">
<p className="truncate font-mono text-xs text-gray-500">
{img.b64.slice(0, 80)}
</p>
</div>
{/* Copy buttons */}
<div className="flex gap-2">
<button
onClick={() => copyToClipboard(img.b64, `b64-${img.id}`)}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-cyan-500/40 hover:text-cyan-400 transition-colors"
>
{copiedId === `b64-${img.id}` ? (
<><Check className="h-3 w-3 text-green-400" /><span className="text-green-400">Copied!</span></>
) : (
<><Copy className="h-3 w-3" />Copy base64</>
)}
</button>
<button
onClick={() => copyToClipboard(jsonSnippet(img), `json-${img.id}`)}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-purple-500/40 hover:text-purple-400 transition-colors"
>
{copiedId === `json-${img.id}` ? (
<><Check className="h-3 w-3 text-green-400" /><span className="text-green-400">Copied!</span></>
) : (
<><Braces className="h-3 w-3" />Copy JSON snippet</>
)}
</button>
<button
onClick={() => setImages((prev) => prev.filter((im) => im.id !== img.id))}
className="ml-auto rounded-lg border border-gray-700 p-1.5 text-gray-600 hover:border-red-500/40 hover:text-red-400 transition-colors"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
); );
} }