import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
// ── Constants ─────────────────────────────────────────────────────────────────
const KNOWN_BOARD_TYPES = [
{ value: "vesper", label: "Vesper", family: "vesper" },
{ value: "vesper_plus", label: "Vesper+", family: "vesper" },
{ value: "vesper_pro", label: "Vesper Pro", family: "vesper" },
{ value: "agnus", label: "Agnus", family: "agnus" },
{ value: "agnus_mini", label: "Agnus Mini", family: "agnus" },
{ value: "chronos", label: "Chronos", family: "chronos" },
{ value: "chronos_pro", label: "Chronos Pro", family: "chronos" },
];
const BOARD_LABELS = Object.fromEntries(KNOWN_BOARD_TYPES.map((b) => [b.value, b.label]));
const FAMILY_ACCENT = {
vesper: "#3b82f6",
agnus: "#f59e0b",
chronos: "#ef4444",
bespoke: "#a855f7",
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function formatDate(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleString("en-US", {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return iso;
}
}
function boardLabel(hw_type, is_bespoke) {
if (is_bespoke) return hw_type;
return BOARD_LABELS[hw_type] || hw_type;
}
function familyOf(hw_type, is_bespoke) {
if (is_bespoke) return "bespoke";
const bt = KNOWN_BOARD_TYPES.find((b) => b.value === hw_type);
return bt?.family ?? "vesper";
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function IconCheck() {
return (
);
}
function IconMissing() {
return (
);
}
function IconTrash() {
return (
);
}
function IconUpload() {
return (
);
}
function IconNote() {
return (
);
}
function IconRefresh() {
return (
);
}
// ── Asset file pill (used inside each board card) ────────────────────────────
function AssetPill({ label, info, canDelete, onDelete, onUpload }) {
const exists = info?.exists;
return (
{exists ? : }
{label}
{exists ? (
{formatBytes(info.size_bytes)} · {formatDate(info.uploaded_at)}
) : (
Not uploaded
)}
{exists && canDelete && (
)}
);
}
// ── Inline Upload Modal ───────────────────────────────────────────────────────
function UploadModal({ hwType, assetName, onClose, onSaved }) {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef(null);
const handleUpload = async () => {
if (!file) return;
setError(""); setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const token = localStorage.getItem("access_token");
const res = await fetch(`/api/manufacturing/flash-assets/${hwType}/${assetName}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
onSaved();
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
e.stopPropagation()}
>
Upload {assetName}
Board: {hwType} — overwrites existing file
{error && (
{error}
)}
fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".bin")) setFile(f);
else if (f) setError("Only .bin files are accepted.");
}}
style={{
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.5rem", padding: "2rem 1rem",
border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`,
borderRadius: "0.625rem",
backgroundColor: file ? "var(--badge-blue-bg)" : "var(--bg-input)",
cursor: "pointer", transition: "all 0.15s ease",
}}
>
{
const f = e.target.files[0];
if (f && !f.name.endsWith(".bin")) { setError("Only .bin files are accepted."); return; }
setFile(f || null); setError("");
}}
style={{ display: "none" }}
/>
{file ? (
<>
{file.name}
{formatBytes(file.size)}
>
) : (
<>
Click or drop {assetName}
>
)}
);
}
// ── Note Editor Modal ─────────────────────────────────────────────────────────
function NoteModal({ hwType, currentNote, onClose, onSaved }) {
const [note, setNote] = useState(currentNote || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleSave = async () => {
setSaving(true); setError("");
try {
await api.put(`/manufacturing/flash-assets/${hwType}/note`, { note });
onSaved(note);
} catch (err) {
setError(err.message || "Failed to save note");
} finally {
setSaving(false);
}
};
return (
e.stopPropagation()}
>
Asset Note
{hwType} — stored as note.txt alongside the binaries
);
}
// ── Delete Confirm Modal ──────────────────────────────────────────────────────
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const handleDelete = async () => {
setDeleting(true); setError("");
try {
const token = localStorage.getItem("access_token");
const res = await fetch(`/api/manufacturing/flash-assets/${hwType}/${assetName}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Delete failed");
}
onConfirmed();
} catch (err) {
setError(err.message);
setDeleting(false);
}
};
return (
e.stopPropagation()}
>
Delete Flash Asset
Delete {assetName} for{" "}
{hwType}?
This cannot be undone. Flashing will fail until you re-upload this file.
{error && (
{error}
)}
);
}
// ── Board Card ────────────────────────────────────────────────────────────────
function BoardCard({ entry, canDelete, canEdit, onUpload, onDelete, onNote, onRefresh }) {
const { hw_type, bootloader, partitions, note, is_bespoke } = entry;
const family = familyOf(hw_type, is_bespoke);
const accent = FAMILY_ACCENT[family] || FAMILY_ACCENT.vesper;
const label = boardLabel(hw_type, is_bespoke);
const hasBootloader = bootloader?.exists;
const hasPartitions = partitions?.exists;
const hasAll = hasBootloader && hasPartitions;
const hasNone = !hasBootloader && !hasPartitions;
const statusColor = hasAll ? "var(--success-text)" : hasNone ? "var(--danger-text)" : "#f59e0b";
const statusBg = hasAll ? "var(--success-bg)" : hasNone ? "var(--danger-bg)" : "#2a1a00";
const statusLabel = hasAll ? "Ready" : hasNone ? "Missing" : "Partial";
return (
{/* Header */}
{label}
{is_bespoke && (
bespoke
)}
·
{hw_type}
{statusLabel}
{canEdit && (
)}
{/* Note preview */}
{note && (
)}
{/* Asset pills */}
onUpload(hw_type, "bootloader.bin")}
onDelete={() => onDelete(hw_type, "bootloader.bin")}
/>
onUpload(hw_type, "partitions.bin")}
onDelete={() => onDelete(hw_type, "partitions.bin")}
/>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function FlashAssetManager({ onClose }) {
const { hasPermission } = useAuth();
const canDelete = hasPermission("manufacturing", "delete");
const canEdit = hasPermission("manufacturing", "edit");
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filter, setFilter] = useState("all"); // "all" | "ready" | "partial" | "missing"
// Modal state
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
const [noteTarget, setNoteTarget] = useState(null); // entry
const fetchAssets = useCallback(async () => {
setLoading(true); setError("");
try {
const data = await api.get("/manufacturing/flash-assets");
setAssets(data.assets || []);
} catch (err) {
setError(err.message || "Failed to load flash assets");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchAssets(); }, [fetchAssets]);
// Filter
const filteredAssets = assets.filter((e) => {
const hasAll = e.bootloader?.exists && e.partitions?.exists;
const hasNone = !e.bootloader?.exists && !e.partitions?.exists;
if (filter === "ready") return hasAll;
if (filter === "missing") return hasNone;
if (filter === "partial") return !hasAll && !hasNone;
return true;
});
// Stats
const readyCount = assets.filter((e) => e.bootloader?.exists && e.partitions?.exists).length;
const missingCount = assets.filter((e) => !e.bootloader?.exists && !e.partitions?.exists).length;
const partialCount = assets.length - readyCount - missingCount;
return (
e.stopPropagation()}
>
{/* ── Header ── */}
Flash Asset Manager
Bootloader and partition table binaries per board type — PlatformIO build artifacts flashed at 0x1000 and 0x8000.
{/* Stats */}
{!loading && (
{[
{ label: "Ready", count: readyCount, color: "var(--success-text)", bg: "var(--success-bg)", key: "ready" },
{ label: "Partial", count: partialCount, color: "#f59e0b", bg: "#2a1a00", key: "partial" },
{ label: "Missing", count: missingCount, color: "var(--danger-text)", bg: "var(--danger-bg)", key: "missing" },
].map(({ label, count, color, bg, key }) => count > 0 && (
))}
{filter !== "all" && (
)}
)}
{/* ── Body ── */}
{error && (
{error}
)}
{loading ? (
Loading…
) : filteredAssets.length === 0 ? (
No assets match the current filter.
) : (
<>
{/* Standard boards section */}
{(() => {
const standard = filteredAssets.filter((e) => !e.is_bespoke);
if (standard.length === 0) return null;
return (
Standard Boards
{standard.map((entry) => (
setUploadTarget({ hwType, assetName: asset })}
onDelete={(hwType, asset) => setDeleteTarget({ hwType, assetName: asset })}
onNote={(e) => setNoteTarget(e)}
onRefresh={fetchAssets}
/>
))}
);
})()}
{/* Bespoke boards section */}
{(() => {
const bespoke = filteredAssets.filter((e) => e.is_bespoke);
if (bespoke.length === 0) return null;
return (
Bespoke Devices
{bespoke.map((entry) => (
setUploadTarget({ hwType, assetName: asset })}
onDelete={(hwType, asset) => setDeleteTarget({ hwType, assetName: asset })}
onNote={(e) => setNoteTarget(e)}
onRefresh={fetchAssets}
/>
))}
);
})()}
>
)}
{/* ── Footer ── */}
{assets.length} board type{assets.length !== 1 ? "s" : ""} tracked
{filteredAssets.length !== assets.length ? ` · ${filteredAssets.length} shown` : ""}
{/* ── Modals ── */}
{uploadTarget && (
setUploadTarget(null)}
onSaved={() => { setUploadTarget(null); fetchAssets(); }}
/>
)}
{deleteTarget && (
setDeleteTarget(null)}
onConfirmed={() => { setDeleteTarget(null); fetchAssets(); }}
/>
)}
{noteTarget && (
setNoteTarget(null)}
onSaved={(newNote) => {
// Optimistically update local state
setAssets((prev) => prev.map((e) => e.hw_type === noteTarget.hw_type ? { ...e, note: newNote } : e));
setNoteTarget(null);
}}
/>
)}
);
}