update: Added asset upload for bespoke boards
This commit is contained in:
@@ -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 }) => (
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>{label}</label>
|
||||
<div
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
<input ref={inputRef} type="file" accept=".bin" style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files[0];
|
||||
if (f && !f.name.endsWith(".bin")) { setError("Only .bin files are accepted."); return; }
|
||||
setFile(f || null); setError("");
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: file ? "var(--btn-primary)" : "var(--text-muted)", flexShrink: 0 }}>
|
||||
{file ? <IconCheck /> : <IconUpload />}
|
||||
</span>
|
||||
<span className="font-mono" style={{ fontSize: "0.72rem", color: file ? "var(--badge-blue-text)" : "var(--text-muted)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{file ? `${file.name} (${formatBytes(file.size)})` : `click or drop ${label}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border w-full mx-4 flex flex-col"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "480px" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 pt-4 pb-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Add Bespoke Board</h3>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Create a new bespoke flash asset set with a unique UID.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="px-5 py-4" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{error && (
|
||||
<div className="text-xs rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Bespoke UID <span style={{ color: "var(--danger-text)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={uid}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-xs mt-1 font-mono" style={{ color: isValidUid && !isKnown && !isDuplicate ? "var(--text-muted)" : "var(--danger-text)" }}>
|
||||
{isDuplicate ? "Already exists" : isKnown ? "Matches a standard board type" : !isValidUid ? "Invalid format" : `→ ${uidSlug}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FilePicker label="bootloader.bin" file={bootFile} setFile={setBootFile} inputRef={bootRef} />
|
||||
<FilePicker label="partitions.bin" file={partFile} setFile={setPartFile} inputRef={partRef} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
|
||||
<button type="button" onClick={onClose} className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !uidSlug}
|
||||
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
{uploading ? "Uploading…" : "Add Board"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
<IconRefresh />
|
||||
Refresh
|
||||
</button>
|
||||
{canAdd && (
|
||||
<button
|
||||
onClick={() => setShowAddBespoke(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md font-medium cursor-pointer hover:opacity-90 transition-opacity"
|
||||
style={{ backgroundColor: "#2a0a3a", color: "#a855f7", border: "1px solid #a855f7" }}
|
||||
>
|
||||
+ Bespoke Board
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-lg leading-none hover:opacity-70 cursor-pointer"
|
||||
@@ -788,6 +954,14 @@ export default function FlashAssetManager({ onClose }) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAddBespoke && (
|
||||
<AddBespokeModal
|
||||
existingUids={assets.filter((e) => e.is_bespoke).map((e) => e.hw_type)}
|
||||
onClose={() => setShowAddBespoke(false)}
|
||||
onSaved={() => { setShowAddBespoke(false); fetchAssets(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user