feat(rt-import): add Image to Base64 converter utility
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user