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. +

+
+ +
+ +
+
+ {error && ( +
+ {error} +
+ )} +
+ + { setUid(e.target.value); setError(""); }} + placeholder="e.g. client-athens-v1" + required + className="w-full px-3 py-2 rounded-md text-sm border font-mono" + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} + /> + {uidSlug && ( +

+ {isDuplicate ? "Already exists" : isKnown ? "Matches a standard board type" : !isValidUid ? "Invalid format" : `→ ${uidSlug}`} +

+ )} +
+ + +
+ +
+ + +
+
+
+
+ ); +} + // ── 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 && ( + + )}