diff --git a/frontend/src/manufacturing/FlashAssetManager.jsx b/frontend/src/manufacturing/FlashAssetManager.jsx
index 9d9b2f1..cf2cccc 100644
--- a/frontend/src/manufacturing/FlashAssetManager.jsx
+++ b/frontend/src/manufacturing/FlashAssetManager.jsx
@@ -373,6 +373,161 @@ function NoteModal({ hwType, currentNote, onClose, onSaved }) {
);
}
+// ── Add Bespoke Board Modal ───────────────────────────────────────────────────
+
+function AddBespokeModal({ existingUids, onClose, onSaved }) {
+ const [uid, setUid] = useState("");
+ const [bootFile, setBootFile] = useState(null);
+ const [partFile, setPartFile] = useState(null);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState("");
+ const bootRef = useRef(null);
+ const partRef = useRef(null);
+
+ const uidSlug = uid.trim().toLowerCase().replace(/\s+/g, "-");
+ const isValidUid = /^[a-z0-9][a-z0-9._-]{0,126}$/.test(uidSlug);
+ const isKnown = KNOWN_BOARD_TYPES.some((b) => b.value === uidSlug);
+ const isDuplicate = existingUids.includes(uidSlug);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ if (!isValidUid) { setError("UID must be lowercase alphanumeric with hyphens/dots only."); return; }
+ if (isKnown) { setError("That name matches a standard board type. Use a unique bespoke UID."); return; }
+ if (isDuplicate) { setError("A bespoke board with that UID already exists."); return; }
+ if (!bootFile && !partFile) { setError("Upload at least one file."); return; }
+ setError(""); setUploading(true);
+ const token = localStorage.getItem("access_token");
+ try {
+ for (const [asset, file] of [["bootloader.bin", bootFile], ["partitions.bin", partFile]]) {
+ if (!file) continue;
+ const fd = new FormData();
+ fd.append("file", file);
+ const res = await fetch(`/api/manufacturing/flash-assets/${uidSlug}/${asset}`, {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: fd,
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.detail || `Failed to upload ${asset}`);
+ }
+ }
+ onSaved();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const FilePicker = ({ label, file, setFile, inputRef }) => (
+
+
+
inputRef.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", alignItems: "center", gap: "0.5rem",
+ padding: "0.5rem 0.75rem",
+ border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`,
+ borderRadius: "0.5rem",
+ 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("");
+ }}
+ />
+
+ {file ? : }
+
+
+ {file ? `${file.name} (${formatBytes(file.size)})` : `click or drop ${label}`}
+
+
+
+ );
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
Add Bespoke Board
+
+ Create a new bespoke flash asset set with a unique UID.
+
+
+
+
+
+
+
+
+ );
+}
+
// ── Delete Confirm Modal ──────────────────────────────────────────────────────
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
@@ -548,9 +703,11 @@ export default function FlashAssetManager({ onClose }) {
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 [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
+ const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
+ const [noteTarget, setNoteTarget] = useState(null); // entry
+ const [showAddBespoke, setShowAddBespoke] = useState(false);
+ const canAdd = hasPermission("manufacturing", "add");
const fetchAssets = useCallback(async () => {
setLoading(true); setError("");
@@ -653,6 +810,15 @@ export default function FlashAssetManager({ onClose }) {
Refresh
+ {canAdd && (
+
+ )}
)}
+
+ {showAddBespoke && (
+ e.is_bespoke).map((e) => e.hw_type)}
+ onClose={() => setShowAddBespoke(false)}
+ onSaved={() => { setShowAddBespoke(false); fetchAssets(); }}
+ />
+ )}
);
}