update: added assets manager and extra nvs settings on cloudflash
This commit is contained in:
793
frontend/src/manufacturing/FlashAssetManager.jsx
Normal file
793
frontend/src/manufacturing/FlashAssetManager.jsx
Normal file
@@ -0,0 +1,793 @@
|
||||
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 (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconMissing() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTrash() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconUpload() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconNote() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconRefresh() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Asset file pill (used inside each board card) ────────────────────────────
|
||||
|
||||
function AssetPill({ label, info, canDelete, onDelete, onUpload }) {
|
||||
const exists = info?.exists;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: "0.5rem",
|
||||
padding: "0.375rem 0.625rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: `1px solid ${exists ? "var(--border-secondary)" : "var(--danger)"}`,
|
||||
backgroundColor: exists ? "var(--bg-secondary)" : "var(--danger-bg)",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: exists ? "var(--success-text)" : "var(--danger-text)", flexShrink: 0 }}>
|
||||
{exists ? <IconCheck /> : <IconMissing />}
|
||||
</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="font-mono" style={{ fontSize: "0.7rem", color: exists ? "var(--text-secondary)" : "var(--danger-text)", fontWeight: 600 }}>
|
||||
{label}
|
||||
</div>
|
||||
{exists ? (
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--text-muted)", marginTop: "1px" }}>
|
||||
{formatBytes(info.size_bytes)} · {formatDate(info.uploaded_at)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: "0.65rem", color: "var(--danger-text)", marginTop: "1px", opacity: 0.8 }}>
|
||||
Not uploaded
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={onUpload}
|
||||
title={exists ? "Re-upload" : "Upload"}
|
||||
className="flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity rounded"
|
||||
style={{
|
||||
width: "24px", height: "24px",
|
||||
backgroundColor: "var(--bg-card-hover)",
|
||||
border: "1px solid var(--border-primary)",
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<IconUpload />
|
||||
</button>
|
||||
{exists && canDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
title="Delete"
|
||||
className="flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity rounded"
|
||||
style={{
|
||||
width: "24px", height: "24px",
|
||||
backgroundColor: "var(--danger-bg)",
|
||||
border: "1px solid var(--danger)",
|
||||
color: "var(--danger-text)",
|
||||
}}
|
||||
>
|
||||
<IconTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<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)" }}>
|
||||
Upload {assetName}
|
||||
</h3>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Board: <span className="font-mono">{hwType}</span> — overwrites existing file
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>✕</button>
|
||||
</div>
|
||||
|
||||
<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
|
||||
onClick={() => 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",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".bin"
|
||||
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("");
|
||||
}}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
{file ? (
|
||||
<>
|
||||
<span style={{ color: "var(--btn-primary)" }}><IconCheck /></span>
|
||||
<span style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>{file.name}</span>
|
||||
<span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}>{formatBytes(file.size)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ color: "var(--text-muted)" }}><IconUpload /></span>
|
||||
<span style={{ fontSize: "0.8rem", color: "var(--text-muted)", textAlign: "center" }}>
|
||||
Click or drop <span className="font-mono">{assetName}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
|
||||
<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
|
||||
onClick={handleUpload}
|
||||
disabled={!file || uploading}
|
||||
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…" : "Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<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"
|
||||
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)" }}>Asset Note</h3>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
<span className="font-mono">{hwType}</span> — stored as <span className="font-mono">note.txt</span> alongside the binaries
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>✕</button>
|
||||
</div>
|
||||
<div className="px-5 py-4">
|
||||
{error && (
|
||||
<div className="text-xs rounded-md p-3 mb-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234. Partition layout changed — reflash required."
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)", resize: "vertical" }}
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: "var(--text-muted)" }}>Leave blank to clear the note.</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
|
||||
<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
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
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)" }}
|
||||
>
|
||||
{saving ? "Saving…" : "Save Note"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<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 p-6 max-w-sm w-full mx-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Delete Flash Asset</h3>
|
||||
<p className="text-sm mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
Delete <span className="font-mono" style={{ color: "var(--text-primary)" }}>{assetName}</span> for{" "}
|
||||
<span className="font-mono" style={{ color: "var(--text-primary)" }}>{hwType}</span>?
|
||||
</p>
|
||||
<p className="text-xs mb-5" style={{ color: "var(--text-muted)" }}>
|
||||
This cannot be undone. Flashing will fail until you re-upload this file.
|
||||
</p>
|
||||
{error && (
|
||||
<div className="text-xs rounded-md p-2 mb-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||
>
|
||||
{deleting ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
className="rounded-lg border"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderColor: "var(--border-primary)",
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
display: "flex", flexDirection: "column",
|
||||
gap: "0",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-4 pt-3 pb-2" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.5rem" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", minWidth: 0, flexWrap: "wrap" }}>
|
||||
<span className="text-sm font-semibold" style={{ color: "var(--text-heading)", lineHeight: 1 }}>{label}</span>
|
||||
{is_bespoke && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded font-medium" style={{ backgroundColor: "#2a0a3a", color: "#a855f7", border: "1px solid #a855f7", lineHeight: 1 }}>
|
||||
bespoke
|
||||
</span>
|
||||
)}
|
||||
<span style={{ color: "var(--text-muted)", opacity: 0.4, lineHeight: 1, fontSize: "0.75rem" }}>·</span>
|
||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)", opacity: 0.7, lineHeight: 1 }}>{hw_type}</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium"
|
||||
style={{ backgroundColor: statusBg, color: statusColor }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => onNote(entry)}
|
||||
title={note ? "Edit note" : "Add note"}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: note ? "var(--bg-secondary)" : "transparent",
|
||||
border: `1px solid ${note ? "var(--border-secondary)" : "transparent"}`,
|
||||
color: note ? "var(--text-secondary)" : "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
<IconNote />
|
||||
{note ? "Note" : "Add note"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Note preview */}
|
||||
{note && (
|
||||
<div className="px-4 pb-2">
|
||||
<p className="text-xs" style={{ color: "var(--text-muted)", fontStyle: "italic", lineHeight: 1.5 }}>
|
||||
{note}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Asset pills */}
|
||||
<div className="px-4 pb-3" style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
|
||||
<AssetPill
|
||||
label="bootloader.bin"
|
||||
info={bootloader}
|
||||
canDelete={canDelete}
|
||||
onUpload={() => onUpload(hw_type, "bootloader.bin")}
|
||||
onDelete={() => onDelete(hw_type, "bootloader.bin")}
|
||||
/>
|
||||
<AssetPill
|
||||
label="partitions.bin"
|
||||
info={partitions}
|
||||
canDelete={canDelete}
|
||||
onUpload={() => onUpload(hw_type, "partitions.bin")}
|
||||
onDelete={() => onDelete(hw_type, "partitions.bin")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.7)", padding: "0 1rem" }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="rounded-lg border w-full flex flex-col"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderColor: "var(--border-primary)",
|
||||
maxWidth: "960px",
|
||||
height: "70vh",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<div
|
||||
className="flex items-start justify-between px-6 pt-5 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--border-secondary)", flexShrink: 0 }}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||
Flash Asset Manager
|
||||
</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
Bootloader and partition table binaries per board type — PlatformIO build artifacts flashed at 0x1000 and 0x8000.
|
||||
</p>
|
||||
{/* Stats */}
|
||||
{!loading && (
|
||||
<div className="flex gap-3 mt-2">
|
||||
{[
|
||||
{ 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 && (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setFilter(filter === key ? "all" : key)}
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: bg, color,
|
||||
outline: filter === key ? `2px solid ${color}` : "none",
|
||||
outlineOffset: "1px",
|
||||
}}
|
||||
>
|
||||
{count} {label}
|
||||
</button>
|
||||
))}
|
||||
{filter !== "all" && (
|
||||
<button
|
||||
onClick={() => setFilter("all")}
|
||||
className="text-xs px-2 py-0.5 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
|
||||
<button
|
||||
onClick={fetchAssets}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md cursor-pointer hover:opacity-80 transition-opacity disabled:opacity-50"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||
>
|
||||
<IconRefresh />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-lg leading-none hover:opacity-70 cursor-pointer"
|
||||
style={{ color: "var(--text-muted)", marginLeft: "0.25rem" }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16" style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
|
||||
Loading…
|
||||
</div>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-16" style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
|
||||
No assets match the current filter.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Standard boards section */}
|
||||
{(() => {
|
||||
const standard = filteredAssets.filter((e) => !e.is_bespoke);
|
||||
if (standard.length === 0) return null;
|
||||
return (
|
||||
<div style={{ marginBottom: "1.5rem" }}>
|
||||
<p className="text-xs font-semibold uppercase mb-3" style={{ color: "var(--text-muted)", letterSpacing: "0.06em" }}>
|
||||
Standard Boards
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{standard.map((entry) => (
|
||||
<BoardCard
|
||||
key={entry.hw_type}
|
||||
entry={entry}
|
||||
canDelete={canDelete}
|
||||
canEdit={canEdit}
|
||||
onUpload={(hwType, asset) => setUploadTarget({ hwType, assetName: asset })}
|
||||
onDelete={(hwType, asset) => setDeleteTarget({ hwType, assetName: asset })}
|
||||
onNote={(e) => setNoteTarget(e)}
|
||||
onRefresh={fetchAssets}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Bespoke boards section */}
|
||||
{(() => {
|
||||
const bespoke = filteredAssets.filter((e) => e.is_bespoke);
|
||||
if (bespoke.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase mb-3" style={{ color: "var(--text-muted)", letterSpacing: "0.06em" }}>
|
||||
Bespoke Devices
|
||||
</p>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
{bespoke.map((entry) => (
|
||||
<BoardCard
|
||||
key={entry.hw_type}
|
||||
entry={entry}
|
||||
canDelete={canDelete}
|
||||
canEdit={canEdit}
|
||||
onUpload={(hwType, asset) => setUploadTarget({ hwType, assetName: asset })}
|
||||
onDelete={(hwType, asset) => setDeleteTarget({ hwType, assetName: asset })}
|
||||
onNote={(e) => setNoteTarget(e)}
|
||||
onRefresh={fetchAssets}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-3"
|
||||
style={{ borderTop: "1px solid var(--border-secondary)", flexShrink: 0 }}
|
||||
>
|
||||
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{assets.length} board type{assets.length !== 1 ? "s" : ""} tracked
|
||||
{filteredAssets.length !== assets.length ? ` · ${filteredAssets.length} shown` : ""}
|
||||
</p>
|
||||
<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)" }}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Modals ── */}
|
||||
{uploadTarget && (
|
||||
<UploadModal
|
||||
hwType={uploadTarget.hwType}
|
||||
assetName={uploadTarget.assetName}
|
||||
onClose={() => setUploadTarget(null)}
|
||||
onSaved={() => { setUploadTarget(null); fetchAssets(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
hwType={deleteTarget.hwType}
|
||||
assetName={deleteTarget.assetName}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirmed={() => { setDeleteTarget(null); fetchAssets(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{noteTarget && (
|
||||
<NoteModal
|
||||
hwType={noteTarget.hw_type}
|
||||
currentNote={noteTarget.note}
|
||||
onClose={() => 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user