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 ──────────────────────────────────────────────────────
|
// ── Delete Confirm Modal ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
|
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
|
||||||
@@ -551,6 +706,8 @@ export default function FlashAssetManager({ onClose }) {
|
|||||||
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
|
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
|
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
|
||||||
const [noteTarget, setNoteTarget] = useState(null); // entry
|
const [noteTarget, setNoteTarget] = useState(null); // entry
|
||||||
|
const [showAddBespoke, setShowAddBespoke] = useState(false);
|
||||||
|
const canAdd = hasPermission("manufacturing", "add");
|
||||||
|
|
||||||
const fetchAssets = useCallback(async () => {
|
const fetchAssets = useCallback(async () => {
|
||||||
setLoading(true); setError("");
|
setLoading(true); setError("");
|
||||||
@@ -653,6 +810,15 @@ export default function FlashAssetManager({ onClose }) {
|
|||||||
<IconRefresh />
|
<IconRefresh />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-lg leading-none hover:opacity-70 cursor-pointer"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user