update: added assets manager and extra nvs settings on cloudflash

This commit is contained in:
2026-03-19 11:11:29 +02:00
parent d0ac4f1d91
commit 29bbaead86
17 changed files with 2369 additions and 446 deletions

View 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>
);
}