794 lines
33 KiB
JavaScript
794 lines
33 KiB
JavaScript
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>
|
|
);
|
|
}
|