update: overhauled firmware ui. Added public flash page.

This commit is contained in:
2026-03-18 17:49:40 +02:00
parent 4381a6681d
commit d0ac4f1d91
45 changed files with 6798 additions and 1723 deletions

View File

@@ -1,5 +1,7 @@
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuth } from "./auth/AuthContext";
import CloudFlashPage from "./cloudflash/CloudFlashPage";
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
import LoginPage from "./auth/LoginPage";
import MainLayout from "./layout/MainLayout";
import MelodyList from "./melodies/MelodyList";
@@ -106,6 +108,9 @@ function RoleGate({ roles, children }) {
export default function App() {
return (
<Routes>
{/* Public routes — no login required */}
<Route path="/cloudflash" element={<CloudFlashPage />} />
<Route path="/login" element={<LoginPage />} />
<Route
element={
@@ -188,6 +193,9 @@ export default function App() {
<Route path="settings/staff/:id" element={<RoleGate roles={["sysadmin", "admin"]}><StaffDetail /></RoleGate>} />
<Route path="settings/staff/:id/edit" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
{/* Settings - Public Features */}
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1188,7 +1188,7 @@ export default function CustomerDetail() {
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{customer.notes.map((note, i) => (
<div key={i} className="px-3 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--bg-primary)" }}>
<p style={{ color: "var(--text-primary)" }}>{note.text}</p>
<p style={{ color: "var(--text-primary)", whiteSpace: "pre-wrap" }}>{note.text}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
</p>

View File

@@ -1556,6 +1556,7 @@ const TAB_DEFS = [
{ id: "bells", label: "Bell Mechanisms", tone: "bells" },
{ id: "clock", label: "Clock & Alerts", tone: "clock" },
{ id: "warranty", label: "Warranty & Subscription", tone: "warranty" },
{ id: "manage", label: "Manage", tone: "manage" },
{ id: "control", label: "Control", tone: "control" },
];
@@ -1574,6 +1575,104 @@ function calcMaintenanceProgress(lastDate, periodDays) {
return Math.max(0, Math.min(100, (elapsed / total) * 100));
}
// ─── Customer Assign Modal ────────────────────────────────────────────────────
function CustomerAssignModal({ deviceId, onSelect, onCancel }) {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false);
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
const search = useCallback(async (q) => {
setSearching(true);
try {
const data = await api.get(`/devices/${deviceId}/customer-search?q=${encodeURIComponent(q)}`);
setResults(data.results || []);
} catch {
setResults([]);
} finally {
setSearching(false);
}
}, [deviceId]);
useEffect(() => {
const t = setTimeout(() => search(query), 250);
return () => clearTimeout(t);
}, [query, search]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-xl border w-full max-w-lg flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "80vh" }}
>
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Assign to Customer</h3>
<button type="button" onClick={onCancel} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>&#x2715;</button>
</div>
<div className="px-6 pt-4 pb-2">
<div style={{ position: "relative" }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, email, phone, org, tags…"
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)" }}
/>
{searching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
</div>
<div className="overflow-y-auto flex-1 px-6 pb-4">
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-secondary)", minHeight: 60 }}>
{results.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"}
</p>
) : (
results.map((c) => (
<button
key={c.id}
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium block">
{[c.name, c.surname].filter(Boolean).join(" ")}
{c.city && (
<>
<span className="mx-1.5" style={{ color: "var(--text-muted)", fontSize: "8px", verticalAlign: "middle" }}></span>
<span style={{ color: "var(--text-muted)", fontWeight: 400 }}>{c.city}</span>
</>
)}
</span>
{c.organization && (
<span className="text-xs block" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
)}
</button>
))
)}
</div>
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
<button
onClick={onCancel}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
);
}
export default function DeviceDetail() {
const { id } = useParams();
const navigate = useNavigate();
@@ -1630,18 +1729,29 @@ export default function DeviceDetail() {
const d = await api.get(`/devices/${id}`);
setDevice(d);
if (d.staffNotes) setStaffNotes(d.staffNotes);
if (Array.isArray(d.tags)) setTags(d.tags);
setLoading(false);
// Phase 2: fire async background fetches — do not block the render
if (d.device_id) {
const deviceSN = d.serial_number || d.device_id;
if (deviceSN) {
api.get("/mqtt/status").then((mqttData) => {
if (mqttData?.devices) {
const match = mqttData.devices.find((s) => s.device_serial === d.device_id);
const match = mqttData.devices.find((s) => s.device_serial === deviceSN);
setMqttStatus(match || null);
}
}).catch(() => {});
}
// Fetch owner customer details
if (d.customer_id) {
api.get(`/devices/${id}/customer`).then((res) => {
setOwnerCustomer(res.customer || null);
}).catch(() => setOwnerCustomer(null));
} else {
setOwnerCustomer(null);
}
setUsersLoading(true);
api.get(`/devices/${id}/users`).then((data) => {
setDeviceUsers(data.users || []);
@@ -1650,9 +1760,9 @@ export default function DeviceDetail() {
}).finally(() => setUsersLoading(false));
// Fetch manufacturing record + product catalog to resolve hw image
if (d.device_id) {
if (deviceSN) {
Promise.all([
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
api.get(`/manufacturing/devices/${deviceSN}`).catch(() => null),
api.get("/crm/products").catch(() => null),
]).then(([mfgItem, productsRes]) => {
const hwType = mfgItem?.hw_type || "";
@@ -1719,6 +1829,85 @@ export default function DeviceDetail() {
}
};
// --- Device Notes handlers ---
const handleAddNote = async () => {
if (!newNoteText.trim()) return;
setSavingNote(true);
try {
const data = await api.post(`/devices/${id}/notes`, {
content: newNoteText.trim(),
created_by: "admin",
});
setDeviceNotes((prev) => [data, ...prev]);
setNewNoteText("");
setAddingNote(false);
} catch {} finally { setSavingNote(false); }
};
const handleUpdateNote = async (noteId) => {
if (!editingNoteText.trim()) return;
setSavingNote(true);
try {
const data = await api.put(`/devices/${id}/notes/${noteId}`, { content: editingNoteText.trim() });
setDeviceNotes((prev) => prev.map((n) => n.id === noteId ? data : n));
setEditingNoteId(null);
} catch {} finally { setSavingNote(false); }
};
const handleDeleteNote = async (noteId) => {
if (!window.confirm("Delete this note?")) return;
try {
await api.delete(`/devices/${id}/notes/${noteId}`);
setDeviceNotes((prev) => prev.filter((n) => n.id !== noteId));
} catch {}
};
// --- Tags handlers ---
const handleAddTag = async (tag) => {
const trimmed = tag.trim();
if (!trimmed || tags.includes(trimmed)) return;
const next = [...tags, trimmed];
setSavingTags(true);
try {
await api.put(`/devices/${id}/tags`, { tags: next });
setTags(next);
setTagInput("");
} catch {} finally { setSavingTags(false); }
};
const handleRemoveTag = async (tag) => {
const next = tags.filter((t) => t !== tag);
setSavingTags(true);
try {
await api.put(`/devices/${id}/tags`, { tags: next });
setTags(next);
} catch {} finally { setSavingTags(false); }
};
// --- Customer assign handlers ---
const handleAssignCustomer = async (customer) => {
setAssigningCustomer(true);
try {
await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id });
setDevice((prev) => ({ ...prev, customer_id: customer.id }));
setOwnerCustomer(customer);
setShowAssignSearch(false);
setCustomerSearch("");
setCustomerResults([]);
} catch {} finally { setAssigningCustomer(false); }
};
const handleUnassignCustomer = async () => {
if (!window.confirm("Remove customer assignment?")) return;
setAssigningCustomer(true);
try {
const cid = device?.customer_id;
await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ""}`);
setDevice((prev) => ({ ...prev, customer_id: "" }));
setOwnerCustomer(null);
} catch {} finally { setAssigningCustomer(false); }
};
const requestStrikeCounters = useCallback(async (force = false) => {
if (!device?.device_id) return;
const now = Date.now();
@@ -1800,6 +1989,66 @@ export default function DeviceDetail() {
return () => clearInterval(interval);
}, [ctrlCmdAutoRefresh, device?.device_id, fetchCtrlCmdHistory]);
// --- Device Notes state (MUST be before early returns) ---
const [deviceNotes, setDeviceNotes] = useState([]);
const [notesLoaded, setNotesLoaded] = useState(false);
const [addingNote, setAddingNote] = useState(false);
const [newNoteText, setNewNoteText] = useState("");
const [savingNote, setSavingNote] = useState(false);
const [editingNoteId, setEditingNoteId] = useState(null);
const [editingNoteText, setEditingNoteText] = useState("");
const loadDeviceNotes = useCallback(async () => {
try {
const data = await api.get(`/devices/${id}/notes`);
setDeviceNotes(data.notes || []);
setNotesLoaded(true);
} catch {
setNotesLoaded(true);
}
}, [id]);
useEffect(() => {
if (id) loadDeviceNotes();
}, [id, loadDeviceNotes]);
// --- Tags state (MUST be before early returns) ---
const [tags, setTags] = useState([]);
const [tagInput, setTagInput] = useState("");
const [savingTags, setSavingTags] = useState(false);
// --- Customer assign state (MUST be before early returns) ---
const [assigningCustomer, setAssigningCustomer] = useState(false);
const [showAssignSearch, setShowAssignSearch] = useState(false);
const [ownerCustomer, setOwnerCustomer] = useState(null);
// --- User assignment state (MUST be before early returns) ---
const [showUserSearch, setShowUserSearch] = useState(false);
const [userSearchQuery, setUserSearchQuery] = useState("");
const [userSearchResults, setUserSearchResults] = useState([]);
const [userSearching, setUserSearching] = useState(false);
const [addingUser, setAddingUser] = useState(null);
const [removingUser, setRemovingUser] = useState(null);
const userSearchInputRef = useRef(null);
const searchUsers = useCallback(async (q) => {
setUserSearching(true);
try {
const data = await api.get(`/devices/${id}/user-search?q=${encodeURIComponent(q)}`);
setUserSearchResults(data.results || []);
} catch {
setUserSearchResults([]);
} finally {
setUserSearching(false);
}
}, [id]);
useEffect(() => {
if (!showUserSearch) return;
const t = setTimeout(() => searchUsers(userSearchQuery), 250);
return () => clearTimeout(t);
}, [userSearchQuery, searchUsers, showUserSearch]);
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
if (error) return (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
@@ -1952,7 +2201,7 @@ export default function DeviceDetail() {
<div className="db-row">
<div className="db-info-field">
<span className="db-info-label">SERIAL NUMBER</span>
<span className="db-info-value">{device.device_id || "-"}</span>
<span className="db-info-value">{device.serial_number || device.device_id || "-"}</span>
</div>
</div>
<div className="db-row">
@@ -2115,7 +2364,7 @@ export default function DeviceDetail() {
</div>
<DeviceLogsPanel deviceSerial={device.device_id} />
<DeviceLogsPanel deviceSerial={device.serial_number || device.device_id} />
<div className="dashboard-bottom-grid">
<div className="dashboard-bottom-grid__notes" ref={notesPanelRef}>
@@ -2253,6 +2502,260 @@ export default function DeviceDetail() {
<EmptyCell />
</FieldRow>
</SectionCard>
{/* ── Tags ── */}
<SectionCard title="Tags">
<div className="flex flex-wrap gap-2 mb-3">
{tags.length === 0 && (
<span className="text-xs" style={{ color: "var(--text-muted)" }}>No tags yet.</span>
)}
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", border: "1px solid var(--badge-blue-text)" }}
>
{tag}
{canEdit && (
<button
type="button"
onClick={() => handleRemoveTag(tag)}
disabled={savingTags}
className="ml-0.5 hover:opacity-70 cursor-pointer disabled:opacity-40"
style={{ lineHeight: 1 }}
>
×
</button>
)}
</span>
))}
</div>
{canEdit && (
<div className="flex gap-2">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(tagInput); } }}
placeholder="Add tag and press Enter…"
className="px-3 py-1.5 rounded-md text-sm border flex-1"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/>
<button
type="button"
onClick={() => handleAddTag(tagInput)}
disabled={!tagInput.trim() || savingTags}
className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50 cursor-pointer"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add
</button>
</div>
)}
</SectionCard>
{/* ── Owner ── */}
<SectionCard title="Owner">
{device.customer_id ? (
<div>
{ownerCustomer ? (
<div
className="flex items-center gap-3 p-3 rounded-md border mb-3 cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
onClick={() => navigate(`/crm/customers/${device.customer_id}`)}
title="View customer"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(ownerCustomer.name || "?")[0].toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{ownerCustomer.name || "—"}</p>
{ownerCustomer.organization && (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{ownerCustomer.organization}</p>
)}
</div>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
) : (
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>Customer assigned (loading details)</p>
)}
{canEdit && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowAssignSearch(true)}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Reassign
</button>
<button
type="button"
onClick={handleUnassignCustomer}
disabled={assigningCustomer}
className="text-xs px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50 hover:opacity-80"
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
>
Remove
</button>
</div>
)}
</div>
) : (
<div>
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>No customer assigned yet.</p>
{canEdit && (
<button
type="button"
onClick={() => setShowAssignSearch(true)}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Assign to Customer
</button>
)}
</div>
)}
{showAssignSearch && (
<CustomerAssignModal
deviceId={id}
onSelect={(c) => { setShowAssignSearch(false); handleAssignCustomer(c); }}
onCancel={() => setShowAssignSearch(false)}
/>
)}
</SectionCard>
{/* ── Device Notes ── */}
<SectionCard title="Device Notes">
{!notesLoaded ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading</p>
) : (
<>
{deviceNotes.length === 0 && !addingNote && (
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>No notes for this device.</p>
)}
<div className="space-y-3 mb-3">
{deviceNotes.map((note) => (
<div
key={note.id}
className="p-3 rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
{editingNoteId === note.id ? (
<div className="space-y-2">
<textarea
value={editingNoteText}
onChange={(e) => setEditingNoteText(e.target.value)}
autoFocus
rows={3}
className="w-full px-2 py-1.5 rounded text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)", resize: "vertical" }}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleUpdateNote(note.id)}
disabled={savingNote}
className="text-xs px-2.5 py-1 rounded-md cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{savingNote ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => setEditingNoteId(null)}
className="text-xs px-2.5 py-1 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-secondary)" }}>{note.content}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.created_by}{note.created_at ? ` · ${new Date(note.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}` : ""}
</p>
</div>
{canEdit && (
<div className="flex gap-1.5 shrink-0">
<button
type="button"
onClick={() => { setEditingNoteId(note.id); setEditingNoteText(note.content); }}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
style={{ color: "var(--text-muted)" }}
>
Edit
</button>
<button
type="button"
onClick={() => handleDeleteNote(note.id)}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
style={{ color: "var(--danger-text)" }}
>
Delete
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
{canEdit && (
addingNote ? (
<div className="space-y-2">
<textarea
value={newNoteText}
onChange={(e) => setNewNoteText(e.target.value)}
autoFocus
rows={3}
placeholder="Write a note…"
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" }}
/>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddNote}
disabled={savingNote || !newNoteText.trim()}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{savingNote ? "Saving…" : "Add Note"}
</button>
<button
type="button"
onClick={() => { setAddingNote(false); setNewNoteText(""); }}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setAddingNote(true)}
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
+ Add Note
</button>
)
)}
</>
)}
</SectionCard>
</div>
</div>
);
@@ -3039,12 +3542,182 @@ export default function DeviceDetail() {
</div>
);
// ── Manage tab ──────────────────────────────────────────────────────────────
const manageTab = (
<div className="device-tab-stack">
{/* Issues & Notes — full width */}
<NotesPanel key={`manage-${id}`} deviceId={id} />
{/* User Assignment */}
<section className="rounded-lg border p-6 mt-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
App Users ({deviceUsers.length})
</h2>
{canEdit && (
<button
type="button"
onClick={() => { setShowUserSearch(true); setUserSearchQuery(""); setUserSearchResults([]); setTimeout(() => userSearchInputRef.current?.focus(), 50); }}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Add User
</button>
)}
</div>
{usersLoading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users</p>
) : deviceUsers.length === 0 ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned. Users are added when they claim the device via the app, or you can add them manually.</p>
) : (
<div className="space-y-2">
{deviceUsers.map((u) => (
<div
key={u.user_id}
className="flex items-center gap-3 px-3 py-2.5 rounded-md border"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
>
{u.photo_url ? (
<img src={u.photo_url} alt="" className="w-8 h-8 rounded-full object-cover shrink-0" />
) : (
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(u.display_name || u.email || "?")[0].toUpperCase()}
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{u.display_name || "—"}</p>
{u.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
</div>
{u.role && (
<span className="text-xs px-2 py-0.5 rounded-full shrink-0"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{u.role}
</span>
)}
{canEdit && (
<button
type="button"
disabled={removingUser === u.user_id}
onClick={async () => {
setRemovingUser(u.user_id);
try {
await api.delete(`/devices/${id}/user-list/${u.user_id}`);
setDeviceUsers((prev) => prev.filter((x) => x.user_id !== u.user_id));
} catch (err) {
setError(err.message);
} finally {
setRemovingUser(null);
}
}}
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 disabled:opacity-40 shrink-0"
style={{ color: "var(--danger-text)" }}
>
{removingUser === u.user_id ? "…" : "Remove"}
</button>
)}
</div>
))}
</div>
)}
{/* User search modal */}
{showUserSearch && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
>
<div
className="rounded-xl border p-6 w-full max-w-md"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>
Add User
</h2>
<div style={{ position: "relative" }} className="mb-3">
<input
ref={userSearchInputRef}
type="text"
value={userSearchQuery}
onChange={(e) => setUserSearchQuery(e.target.value)}
placeholder="Search by name, email, or phone…"
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)" }}
/>
{userSearching && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>
)}
</div>
<div
className="rounded-md border overflow-y-auto mb-4"
style={{ borderColor: "var(--border-secondary)", maxHeight: 260, minHeight: 48 }}
>
{userSearchResults.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{userSearching ? "Searching…" : userSearchQuery ? "No users found." : "Type to search users…"}
</p>
) : (
userSearchResults.map((u) => {
const alreadyAdded = deviceUsers.some((du) => du.user_id === u.id);
return (
<button
key={u.id}
type="button"
disabled={alreadyAdded || addingUser === u.id}
onClick={async () => {
setAddingUser(u.id);
try {
await api.post(`/devices/${id}/user-list`, { user_id: u.id });
setDeviceUsers((prev) => [...prev, {
user_id: u.id,
display_name: u.display_name,
email: u.email,
photo_url: u.photo_url,
role: "",
}]);
setShowUserSearch(false);
} catch (err) {
setError(err.message);
} finally {
setAddingUser(null);
}
}}
className="w-full text-left px-3 py-2.5 text-sm border-b last:border-b-0 transition-colors cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
>
<span className="font-medium">{u.display_name || u.email || u.id}</span>
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
{alreadyAdded && <span className="ml-2 text-xs" style={{ color: "var(--success-text)" }}>Already added</span>}
</button>
);
})
)}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={() => setShowUserSearch(false)}
className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
</div>
</div>
</div>
)}
</section>
</div>
);
const renderTabContent = () => {
if (activeTab === "dashboard") return dashboardTab;
if (activeTab === "general") return generalInfoTab;
if (activeTab === "bells") return bellMechanismsTab;
if (activeTab === "clock") return clockAlertsTab;
if (activeTab === "warranty") return warrantySubscriptionTab;
if (activeTab === "manage") return manageTab;
return controlTab;
};
@@ -3136,7 +3809,7 @@ export default function DeviceDetail() {
<ConfirmDialog
open={showDelete}
title="Delete Device"
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.device_id})? This action cannot be undone.`}
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.serial_number || device.device_id})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setShowDelete(false)}
/>

View File

@@ -31,6 +31,9 @@ const ALL_COLUMNS = [
{ key: "warrantyActive", label: "Warranty Active", defaultOn: false },
{ key: "totalMelodies", label: "Total Melodies", defaultOn: false },
{ key: "assignedUsers", label: "Assigned Users", defaultOn: true },
{ key: "tags", label: "Tags", defaultOn: false },
{ key: "hw_family", label: "HW Family", defaultOn: false },
{ key: "hw_revision", label: "HW Revision", defaultOn: false },
];
function getDefaultVisibleColumns() {
@@ -199,7 +202,8 @@ export default function DeviceList() {
switch (key) {
case "status": {
const mqtt = mqttStatusMap[device.device_id];
const sn = device.serial_number || device.device_id;
const mqtt = mqttStatusMap[sn];
const isOnline = mqtt ? mqtt.online : device.is_Online;
return (
<span
@@ -216,7 +220,7 @@ export default function DeviceList() {
</span>
);
case "serialNumber":
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.device_id || "-"}</span>;
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.serial_number || device.device_id || "-"}</span>;
case "location":
return device.device_location || "-";
case "subscrTier":
@@ -260,8 +264,31 @@ export default function DeviceList() {
return <BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />;
case "totalMelodies":
return device.device_melodies_all?.length ?? 0;
case "assignedUsers":
return device.user_list?.length ?? 0;
case "assignedUsers": {
const ul = Array.isArray(device.user_list) ? device.user_list : [];
return ul.length;
}
case "tags": {
const tagList = Array.isArray(device.tags) ? device.tags : [];
if (tagList.length === 0) return <span style={{ color: "var(--text-muted)" }}></span>;
return (
<div className="flex flex-wrap gap-1">
{tagList.map((t) => (
<span
key={t}
className="px-1.5 py-0.5 text-xs rounded-full"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
>
{t}
</span>
))}
</div>
);
}
case "hw_family":
return device.hw_family || <span style={{ color: "var(--text-muted)" }}></span>;
case "hw_revision":
return device.hw_revision || <span style={{ color: "var(--text-muted)" }}></span>;
default:
return "-";
}
@@ -566,7 +593,7 @@ export default function DeviceList() {
<ConfirmDialog
open={!!deleteTarget}
title="Delete Device"
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.device_id || ""})? This action cannot be undone.`}
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.serial_number || deleteTarget?.device_id || ""})? This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
/>

File diff suppressed because it is too large Load Diff

View File

@@ -145,8 +145,9 @@ const navSections = [
// Bottom settings — always rendered separately at the bottom
const settingsChildren = [
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
{ to: "/settings/public-features", label: "Public Features", icon: SettingsIcon },
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
];
// ─── Helpers ──────────────────────────────────────────────────────────────────

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
@@ -7,13 +8,13 @@ import api from "../api/client";
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
const BOARD_TYPES = [
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
{ value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
{ value: "vesper_pro", name: "Vesper Pro", codename: "vesper-pro", family: "vesper" },
{ value: "vesper_plus", name: "Vesper+", codename: "vesper-plus", family: "vesper" },
{ value: "vesper", name: "Vesper", codename: "vesper-basic", family: "vesper" },
{ value: "agnus", name: "Agnus", codename: "agnus-basic", family: "agnus" },
{ value: "agnus_mini", name: "Agnus Mini", codename: "agnus-mini", family: "agnus" },
{ value: "chronos_pro", name: "Chronos Pro", codename: "chronos-pro", family: "chronos" },
{ value: "chronos", name: "Chronos", codename: "chronos-basic", family: "chronos" },
];
const BOARD_FAMILY_COLORS = {
@@ -22,7 +23,11 @@ const BOARD_FAMILY_COLORS = {
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
};
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
const BOARD_TYPE_LABELS = {
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
agnus: "Agnus", agnus_mini: "Agnus Mini",
chronos: "Chronos", chronos_pro: "Chronos Pro",
};
const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
@@ -44,7 +49,8 @@ const ALL_COLUMNS = [
{ id: "status", label: "Status", default: true },
{ id: "batch", label: "Batch", default: true },
{ id: "created", label: "Created", default: true },
{ id: "owner", label: "Owner", default: true },
{ id: "owner", label: "Customer", default: true },
{ id: "users", label: "Device Users", default: true },
{ id: "name", label: "Device Name", default: false },
];
@@ -89,6 +95,80 @@ function StatusBadge({ status }) {
);
}
function UsersHoverCell({ userList, resolvedUsersMap }) {
const [popupPos, setPopupPos] = useState(null);
const triggerRef = useRef(null);
const count = userList.length;
const handleMouseEnter = () => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
setPopupPos({ top: rect.top + window.scrollY, left: rect.left + window.scrollX });
};
const handleMouseLeave = () => setPopupPos(null);
if (count === 0) return <span className="text-xs" style={{ color: "var(--text-muted)" }}></span>;
return (
<>
<span
ref={triggerRef}
className="px-2 py-0.5 text-xs rounded-full font-medium cursor-default"
style={{ backgroundColor: "#431407", color: "#fb923c" }}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{count} user{count !== 1 ? "s" : ""}
</span>
{popupPos && typeof document !== "undefined" &&
ReactDOM.createPortal(
<div
onMouseEnter={() => setPopupPos(popupPos)}
onMouseLeave={() => setPopupPos(null)}
className="rounded-lg border shadow-xl p-3"
style={{
position: "absolute",
top: popupPos.top - 8,
left: popupPos.left,
transform: "translateY(-100%)",
zIndex: 9999,
minWidth: 220, maxWidth: 300,
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
pointerEvents: "none",
}}
>
<p className="text-xs font-semibold mb-2" style={{ color: "var(--text-muted)" }}>
Assigned Users
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{userList.map((uid) => {
const u = resolvedUsersMap[uid];
const initials = (u?.display_name || u?.email || uid)[0]?.toUpperCase() || "U";
return (
<div key={uid} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{
width: 26, height: 26, borderRadius: "50%", flexShrink: 0,
display: "flex", alignItems: "center", justifyContent: "center",
backgroundColor: "#431407", color: "#fb923c", fontSize: 10, fontWeight: 700,
}}>{initials}</div>
<div style={{ minWidth: 0 }}>
<p className="text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{u?.display_name || uid}
</p>
{u?.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
</div>
</div>
);
})}
</div>
</div>,
document.body
)
}
</>
);
}
function formatHwVersion(v) {
if (!v) return "—";
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
@@ -100,7 +180,7 @@ function formatHwVersion(v) {
function formatDate(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
} catch { return iso; }
}
@@ -676,6 +756,18 @@ export default function DeviceInventory() {
// Column preferences
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
// Pagination
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(1);
// Customer name lookup: { [customer_id]: { name, surname } }
const [customerMap, setCustomerMap] = useState({});
// User display lookup: { [uid]: { display_name, email } }
const [resolvedUsersMap, setResolvedUsersMap] = useState({});
// Hover state for checkbox reveal: rowId | null
const [hoveredRow, setHoveredRow] = useState(null);
// Selection
const [selected, setSelected] = useState(new Set());
const allIds = devices.map((d) => d.id);
@@ -695,12 +787,38 @@ export default function DeviceInventory() {
if (search) params.set("search", search);
if (statusFilter) params.set("status", statusFilter);
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
params.set("limit", "200");
params.set("limit", "500");
const qs = params.toString();
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
setDevices(data.devices);
// Clear selection on refresh
const devs = data.devices || [];
setDevices(devs);
setSelected(new Set());
// Fetch customer names for assigned devices
const customerIds = [...new Set(devs.map((d) => d.customer_id).filter(Boolean))];
if (customerIds.length) {
const entries = await Promise.all(
customerIds.map((id) =>
api.get(`/manufacturing/customers/${id}`)
.then((c) => [id, c])
.catch(() => [id, { name: "", surname: "" }])
)
);
setCustomerMap(Object.fromEntries(entries));
}
// Fetch user display info for all user_list UIDs
const allUids = [...new Set(devs.flatMap((d) => d.user_list || []))];
if (allUids.length) {
const entries = await Promise.all(
allUids.map((uid) =>
api.get(`/users/${uid}`)
.then((u) => [uid, { display_name: u.display_name || "", email: u.email || "" }])
.catch(() => [uid, { display_name: "", email: "" }])
)
);
setResolvedUsersMap(Object.fromEntries(entries));
}
} catch (err) {
setError(err.message);
} finally {
@@ -708,7 +826,7 @@ export default function DeviceInventory() {
}
};
useEffect(() => { fetchDevices(); }, [search, statusFilter, hwTypeFilter]);
useEffect(() => { fetchDevices(); setPage(1); }, [search, statusFilter, hwTypeFilter]);
const updateColVisible = (id, visible) => {
const next = { ...colPrefs.visible, [id]: visible };
@@ -739,6 +857,12 @@ export default function DeviceInventory() {
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
.filter((c) => c && colPrefs.visible[c.id]);
const totalPages = pageSize > 0 ? Math.ceil(devices.length / pageSize) : 1;
const safePage = Math.min(page, Math.max(1, totalPages));
const pagedDevices = pageSize > 0
? devices.slice((safePage - 1) * pageSize, safePage * pageSize)
: devices;
const renderCell = (col, device) => {
switch (col.id) {
case "serial": return (
@@ -756,7 +880,18 @@ export default function DeviceInventory() {
case "status": return <StatusBadge status={device.mfg_status} />;
case "batch": return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.mfg_batch_id || "—"}</span>;
case "created": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{formatDate(device.created_at)}</span>;
case "owner": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.owner || "—"}</span>;
case "owner": {
if (!device.customer_id) return <span className="text-xs" style={{ color: "var(--text-muted)" }}></span>;
const cust = customerMap[device.customer_id];
const fullName = cust ? [cust.name, cust.surname].filter(Boolean).join(" ") : "";
return (
<span className="text-sm font-medium" style={{ color: fullName ? "var(--text-primary)" : "var(--text-muted)", whiteSpace: "nowrap" }}>
{fullName || "—"}
</span>
);
}
case "users":
return <UsersHoverCell userList={device.user_list || []} resolvedUsersMap={resolvedUsersMap} />;
case "name": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.device_name || "—"}</span>;
default: return null;
}
@@ -845,6 +980,17 @@ export default function DeviceInventory() {
onChange={updateColVisible}
onReorder={updateColOrder}
/>
<select
value={String(pageSize)}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-3 py-2 rounded-md text-sm border"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
>
<option value="10">Show 10</option>
<option value="20">Show 20</option>
<option value="50">Show 50</option>
<option value="0">Show All</option>
</select>
</div>
{/* Multi-select action bar */}
@@ -884,24 +1030,29 @@ export default function DeviceInventory() {
{/* Table */}
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div className="overflow-x-auto">
<div className="overflow-x-auto" style={{ overflowX: "auto" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
{/* Checkbox column */}
<th className="px-3 py-3 w-10">
<input
type="checkbox"
checked={allSelected}
onChange={toggleAll}
className="cursor-pointer"
/>
</th>
{visibleCols.map((col) => (
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>
{visibleCols.map((col, colIdx) => (
<th key={col.id}
className="py-3 font-medium"
style={{
color: "var(--text-muted)",
textAlign: col.id === "status" ? "center" : "left",
whiteSpace: "nowrap",
paddingLeft: 16,
paddingRight: colIdx === visibleCols.length - 1 ? 4 : 16,
}}>
{col.label}
</th>
))}
{/* Checkbox header — only shown when something is selected */}
<th style={{ width: 36, minWidth: 36, padding: "0 8px", textAlign: "center" }}>
{selected.size > 0 && (
<input type="checkbox" checked={allSelected} onChange={toggleAll} className="cursor-pointer" />
)}
</th>
</tr>
</thead>
<tbody>
@@ -918,38 +1069,54 @@ export default function DeviceInventory() {
</td>
</tr>
) : (
devices.map((device) => {
pagedDevices.map((device, rowIdx) => {
const isSelected = selected.has(device.id);
const isAlt = rowIdx % 2 === 1;
const isHovered = hoveredRow === device.id;
const showCheckbox = isSelected || selected.size > 0 || isHovered;
return (
<tr
key={device.id}
className="cursor-pointer transition-colors"
style={{
borderBottom: "1px solid var(--border-secondary)",
backgroundColor: isSelected ? "var(--bg-card)" : "",
backgroundColor: isSelected ? "var(--bg-card)" : isAlt ? "var(--bg-row-alt)" : "",
}}
onMouseEnter={(e) => {
setHoveredRow(device.id);
if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)";
}}
onMouseLeave={(e) => {
setHoveredRow(null);
if (!isSelected) e.currentTarget.style.backgroundColor = isAlt ? "var(--bg-row-alt)" : "";
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
>
{/* Checkbox */}
<td className="px-3 py-3" onClick={(e) => toggleRow(device.id, e)}>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="cursor-pointer"
/>
</td>
{/* Data cells */}
{visibleCols.map((col) => (
{visibleCols.map((col, colIdx) => (
<td
key={col.id}
className="px-4 py-3"
className="py-3"
style={{
textAlign: col.id === "status" ? "center" : "left",
paddingLeft: 16,
paddingRight: colIdx === visibleCols.length - 1 ? 4 : 16,
}}
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
>
{renderCell(col, device)}
</td>
))}
{/* Hover-reveal checkbox on the right */}
<td style={{ width: 36, minWidth: 36, padding: "0 8px", textAlign: "center" }}
onClick={(e) => toggleRow(device.id, e)}>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="cursor-pointer"
style={{ opacity: showCheckbox ? 1 : 0, transition: "opacity 0.15s" }}
/>
</td>
</tr>
);
})
@@ -957,6 +1124,42 @@ export default function DeviceInventory() {
</tbody>
</table>
</div>
{pageSize > 0 && totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border-primary)" }}>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
Page {safePage} of {totalPages} {devices.length} total
</span>
<div className="flex items-center gap-1">
<button onClick={() => setPage(1)} disabled={safePage === 1}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>«</button>
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}></button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2)
.reduce((acc, p, idx, arr) => {
if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, idx) => p === "…"
? <span key={`e${idx}`} className="px-1 text-xs" style={{ color: "var(--text-muted)" }}></span>
: <button key={p} onClick={() => setPage(p)}
className="px-2 py-1 text-xs rounded cursor-pointer"
style={{ backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", fontWeight: p === safePage ? 700 : 400 }}>
{p}
</button>
)}
<button onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}></button>
<button onClick={() => setPage(totalPages)} disabled={safePage === totalPages}
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>»</button>
</div>
</div>
)}
</div>
{/* Modals */}

File diff suppressed because it is too large Load Diff

View File

@@ -508,13 +508,168 @@ function BoardTypeTile({ bt, isSelected, pal, onClick }) {
);
}
// ─── Bespoke Picker Modal ─────────────────────────────────────────────────────
function BespokePickerModal({ onConfirm, onClose }) {
const [firmwares, setFirmwares] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [selected, setSelected] = useState(null);
const [hwFamily, setHwFamily] = useState("");
useEffect(() => {
api.get("/firmware?hw_type=bespoke")
.then((data) => setFirmwares(data.firmware || []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, []);
const handleConfirm = () => {
if (!selected) return;
onConfirm({ firmware: selected, hwFamily });
};
return (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div
className="rounded-lg border flex flex-col"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
width: "100%", maxWidth: 560,
maxHeight: "80vh",
margin: "1rem",
}}
>
{/* Header */}
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Select Bespoke Firmware</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Choose a bespoke firmware and the hardware family to register in NVS.
</p>
</div>
<button onClick={onClose} className="cursor-pointer hover:opacity-70 transition-opacity" style={{ color: "var(--text-muted)" }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-5 space-y-4" style={{ minHeight: 0 }}>
{loading && (
<p className="text-sm text-center py-6" style={{ color: "var(--text-muted)" }}>Loading bespoke firmwares</p>
)}
{error && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{!loading && !error && firmwares.length === 0 && (
<p className="text-sm text-center py-6" style={{ color: "var(--text-muted)" }}>
No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.
</p>
)}
{firmwares.length > 0 && (
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>UID</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
</tr>
</thead>
<tbody>
{firmwares.map((fw) => {
const isSelected = selected?.id === fw.id;
return (
<tr
key={fw.id}
onClick={() => setSelected(fw)}
className="cursor-pointer transition-colors"
style={{
borderBottom: "1px solid var(--border-secondary)",
backgroundColor: isSelected ? "var(--badge-blue-bg)" : "",
outline: isSelected ? "1px solid var(--badge-blue-text)" : "none",
}}
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
>
<td className="px-3 py-2.5 font-mono text-xs font-medium" style={{ color: "var(--text-primary)" }}>
{fw.bespoke_uid || "—"}
</td>
<td className="px-3 py-2.5 font-mono text-xs" style={{ color: "var(--text-secondary)" }}>{fw.version}</td>
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>{fw.channel}</td>
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>
{fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* HW Family — free text */}
<div>
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
Hardware Family for NVS
</label>
<p className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
This value will be written to NVS as <span style={{ fontFamily: "monospace" }}>hw_type</span>. The device will identify as this family.
</p>
<input
type="text"
value={hwFamily}
onChange={(e) => setHwFamily(e.target.value)}
placeholder="e.g. vesper_plus"
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)" }}
/>
</div>
</div>
{/* Footer */}
<div className="px-5 py-4 border-t flex items-center justify-between gap-3" style={{ borderColor: "var(--border-primary)" }}>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
hw_revision will be set to <span style={{ fontFamily: "monospace" }}>1.0</span> for bespoke devices.
</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selected || !hwFamily.trim()}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Continue
</button>
</div>
</div>
</div>
</div>
);
}
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
function StepDeployNew({ onSelected, onCreatedSn }) {
const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("1.0");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
function StepDeployNew({ onSelected, onCreatedSn, onBespokeSelected }) {
const [boardType, setBoardType] = useState(null);
const [boardVersion, setBoardVersion] = useState("1.0");
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
const [showBespokePicker, setShowBespokePicker] = useState(false);
const handleCreate = async () => {
if (!boardType || !boardVersion.trim()) return;
@@ -537,6 +692,11 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
}
};
const handleBespokeConfirm = ({ firmware, hwFamily }) => {
setShowBespokePicker(false);
onBespokeSelected({ firmware, hwFamily });
};
// Group boards by family for row layout
const vesperBoards = BOARD_TYPES.filter((b) => b.family === "vesper");
const agnusBoards = BOARD_TYPES.filter((b) => b.family === "agnus");
@@ -626,6 +786,40 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
</button>
</div>
</div>
{/* Bespoke divider */}
<div className="flex items-center gap-3 pt-1">
<div style={{ flex: 1, height: 1, backgroundColor: "var(--border-secondary)" }} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>or</span>
<div style={{ flex: 1, height: 1, backgroundColor: "var(--border-secondary)" }} />
</div>
{/* Bespoke option */}
<div
className="rounded-lg border p-4 flex items-center justify-between gap-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div>
<p className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Select Bespoke Firmware</p>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Flash a one-off bespoke firmware with a custom hardware family written to NVS.
</p>
</div>
<button
onClick={() => setShowBespokePicker(true)}
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)", flexShrink: 0 }}
>
Select Bespoke
</button>
</div>
{showBespokePicker && (
<BespokePickerModal
onConfirm={handleBespokeConfirm}
onClose={() => setShowBespokePicker(false)}
/>
)}
</div>
);
}
@@ -643,7 +837,7 @@ function InfoCell({ label, value, mono = false }) {
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
function StepFlash({ device, onFlashed }) {
function StepFlash({ device, bespokeOverride, onFlashed }) {
const [portConnected, setPortConnected] = useState(false);
const [portName, setPortName] = useState("");
const [connecting, setConnecting] = useState(false);
@@ -791,20 +985,35 @@ function StepFlash({ device, onFlashed }) {
// 1. Fetch binaries
const sn = device.serial_number;
// For bespoke: use the hwFamily's flash assets and override NVS params.
// The bootloader/partitions endpoints accept an optional hw_type_override query param.
const blUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/bootloader.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/bootloader.bin`;
const partUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
: `/api/manufacturing/devices/${sn}/partitions.bin`;
const nvsUrl = bespokeOverride
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0`
: `/api/manufacturing/devices/${sn}/nvs.bin`;
appendLog("Fetching bootloader binary…");
const blBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/bootloader.bin`);
const blBuffer = await fetchBinary(blUrl);
appendLog(`Bootloader: ${blBuffer.byteLength} bytes`);
appendLog("Fetching partition table binary…");
const partBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/partitions.bin`);
const partBuffer = await fetchBinary(partUrl);
appendLog(`Partition table: ${partBuffer.byteLength} bytes`);
appendLog("Fetching NVS binary…");
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/nvs.bin`);
const nvsBuffer = await fetchBinary(nvsUrl);
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
appendLog("Fetching firmware binary…");
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/firmware.bin`);
const fwUrl = bespokeOverride
? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin`
: `/api/manufacturing/devices/${sn}/firmware.bin`;
const fwBuffer = await fetchBinary(fwUrl);
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
// 2. Connect ESPLoader
@@ -924,7 +1133,23 @@ function StepFlash({ device, onFlashed }) {
<div className="grid grid-cols-2 gap-3 mb-4">
<InfoCell label="Serial Number" value={device.serial_number} mono />
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
{bespokeOverride ? (
<>
<InfoCell label="NVS hw_type" value={bespokeOverride.hwFamily} />
<InfoCell label="NVS hw_revision" value="1.0" />
<div>
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Firmware</p>
<p className="text-xs font-mono" style={{ color: "#fb923c" }}>
BESPOKE · {bespokeOverride.firmware.bespoke_uid}
</p>
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>
v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}
</p>
</div>
</>
) : (
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
)}
</div>
{!webSerialAvailable && (
@@ -1382,6 +1607,8 @@ export default function ProvisioningWizard() {
const [mode, setMode] = useState(null); // "existing" | "new"
const [device, setDevice] = useState(null);
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
// Bespoke override: { firmware: FirmwareVersion, hwFamily: string } | null
const [bespokeOverride, setBespokeOverride] = useState(null);
const handleModePicked = (m) => {
setMode(m);
@@ -1389,10 +1616,38 @@ export default function ProvisioningWizard() {
};
const handleDeviceSelected = (dev) => {
setBespokeOverride(null);
setDevice(dev);
setStep(2);
};
// Called from StepDeployNew when user picks a bespoke firmware.
// We skip serial generation and go straight to flash with a synthetic device stub.
const handleBespokeSelected = ({ firmware, hwFamily }) => {
setBespokeOverride({ firmware, hwFamily });
// Create a minimal device-like object — serial will be generated on this step
// but for bespoke we still need a real serial. Trigger normal Deploy New flow
// with a placeholder boardType that maps to hwFamily, then override at flash time.
// For simplicity: generate a serial with board_type=hwFamily, board_version="1.0".
(async () => {
try {
const batch = await api.post("/manufacturing/batch", {
board_type: "vesper", // placeholder — NVS will be overridden with the bespoke hwFamily
board_version: "1.0",
quantity: 1,
});
const sn = batch.serial_numbers[0];
setCreatedSn(sn);
const dev = await api.get(`/manufacturing/devices/${sn}`);
setDevice(dev);
setStep(2);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to create bespoke serial:", err);
}
})();
};
const handleFlashed = () => setStep(3);
const handleVerified = (updatedDevice) => {
@@ -1410,9 +1665,11 @@ export default function ProvisioningWizard() {
setStep(0);
setMode(null);
setDevice(null);
setBespokeOverride(null);
} else if (step === 2) {
setStep(1);
setDevice(null);
setBespokeOverride(null);
} else if (step === 3) {
setStep(2);
}
@@ -1426,11 +1683,13 @@ export default function ProvisioningWizard() {
setStep(0);
setMode(null);
setDevice(null);
setBespokeOverride(null);
};
const handleProvisionNext = () => {
setDevice(null);
setCreatedSn(null);
setBespokeOverride(null);
setStep(0);
setMode(null);
};
@@ -1511,11 +1770,12 @@ export default function ProvisioningWizard() {
<StepDeployNew
onSelected={handleDeviceSelected}
onCreatedSn={(sn) => setCreatedSn(sn)}
onBespokeSelected={handleBespokeSelected}
/>
)}
{step === 2 && device && (
<StepFlash device={device} onFlashed={handleFlashed} />
<StepFlash device={device} bespokeOverride={bespokeOverride} onFlashed={handleFlashed} />
)}
{step === 3 && device && (
<StepVerify device={device} onVerified={handleVerified} />

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import api from "../api/client";
const MAX_NOTES = 16;
@@ -106,10 +106,48 @@ function playStep(audioCtx, stepValue, noteDurationMs) {
}
}
function csvToSteps(csv) {
if (!csv || !csv.trim()) return null;
return csv.trim().split(",").map((token) => {
const parts = token.split("+");
let val = 0;
for (const p of parts) {
const n = parseInt(p.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1));
}
return val;
});
}
export default function MelodyComposer() {
const navigate = useNavigate();
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
const [noteCount, setNoteCount] = useState(8);
const { state: routeState } = useLocation();
const loadedArchetype = routeState?.archetype || null;
const initialSteps = () => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) return parsed;
}
return Array.from({ length: 16 }, () => 0);
};
const [steps, setSteps] = useState(initialSteps);
const [noteCount, setNoteCount] = useState(() => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) {
let maxBit = 0;
for (const v of parsed) {
for (let b = 15; b >= 0; b--) {
if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break; }
}
}
return Math.max(8, maxBit);
}
}
return 8;
});
const [stepDelayMs, setStepDelayMs] = useState(280);
const [noteDurationMs, setNoteDurationMs] = useState(110);
const [measureEvery, setMeasureEvery] = useState(4);
@@ -123,6 +161,7 @@ export default function MelodyComposer() {
const [deployPid, setDeployPid] = useState("");
const [deployError, setDeployError] = useState("");
const [deploying, setDeploying] = useState(false);
const [deployMode, setDeployMode] = useState("new"); // "new" | "update"
const [noteColors, setNoteColors] = useState([]);
const [stepMenuIndex, setStepMenuIndex] = useState(null);
@@ -290,10 +329,18 @@ export default function MelodyComposer() {
scheduleStep(0);
};
const openDeployModal = () => {
const openDeployModal = (mode = "new") => {
setError("");
setSuccessMsg("");
setDeployError("");
setDeployMode(mode);
if (mode === "update" && loadedArchetype) {
setDeployName(loadedArchetype.name || "");
setDeployPid(loadedArchetype.pid || "");
} else {
setDeployName("");
setDeployPid("");
}
setShowDeployModal(true);
};
@@ -306,45 +353,34 @@ export default function MelodyComposer() {
const handleDeploy = async () => {
const name = deployName.trim();
const pid = deployPid.trim();
if (!name) {
setDeployError("Name is required.");
return;
}
if (!pid) {
setDeployError("PID is required.");
return;
}
if (!name) { setDeployError("Name is required."); return; }
if (!pid) { setDeployError("PID is required."); return; }
setDeploying(true);
setDeployError("");
setSuccessMsg("");
try {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (dupName) {
setDeployError(`An archetype with the name "${name}" already exists.`);
return;
}
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
if (dupPid) {
setDeployError(`An archetype with the PID "${pid}" already exists.`);
return;
}
const stepsStr = steps.map(stepToNotation).join(",");
const created = await api.post("/builder/melodies", {
name,
pid,
steps: stepsStr,
});
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
setShowDeployModal(false);
setDeployName("");
setDeployPid("");
if (created?.id) {
navigate(`/melodies/archetypes/${created.id}`);
if (deployMode === "update" && loadedArchetype?.id) {
const updated = await api.put(`/builder/melodies/${loadedArchetype.id}`, { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" updated successfully.`);
setShowDeployModal(false);
if (updated?.id) navigate(`/melodies/archetypes/${updated.id}`);
} else {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (dupName) { setDeployError(`An archetype with the name "${name}" already exists.`); return; }
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
if (dupPid) { setDeployError(`An archetype with the PID "${pid}" already exists.`); return; }
const created = await api.post("/builder/melodies", { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
setShowDeployModal(false);
setDeployName("");
setDeployPid("");
if (created?.id) navigate(`/melodies/archetypes/${created.id}`);
}
} catch (err) {
setDeployError(err.message);
@@ -375,6 +411,24 @@ export default function MelodyComposer() {
</p>
</div>
{loadedArchetype && (
<div className="rounded-lg border px-4 py-3 flex items-center gap-3"
style={{ backgroundColor: "rgba(139,92,246,0.08)", borderColor: "rgba(139,92,246,0.3)" }}>
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: "#a78bfa" }}>Editing Archetype</span>
<span className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{loadedArchetype.name}</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>·</span>
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{loadedArchetype.id}</span>
<button
type="button"
onClick={() => navigate("/melodies/composer", { replace: true, state: null })}
className="ml-auto text-xs px-2 py-1 rounded"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
Clear
</button>
</div>
)}
{error && (
<div
className="text-sm rounded-md p-3 border"
@@ -422,7 +476,14 @@ export default function MelodyComposer() {
)}
<span>{steps.length} steps, {noteCount} notes</span>
</div>
<button type="button" onClick={openDeployModal} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
{loadedArchetype ? (
<>
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>Deploy as New Archetype</button>
<button type="button" onClick={() => openDeployModal("update")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Update Current Archetype</button>
</>
) : (
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
)}
</div>
</div>
@@ -666,10 +727,12 @@ export default function MelodyComposer() {
>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
Deploy Archetype
{deployMode === "update" ? "Update Archetype" : "Deploy Archetype"}
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Create a new archetype from this composer pattern.
{deployMode === "update"
? "Rebuild the existing archetype with the current composer pattern."
: "Create a new archetype from this composer pattern."}
</p>
</div>
<button
@@ -744,7 +807,7 @@ export default function MelodyComposer() {
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
disabled={deploying}
>
{deploying ? "Deploying..." : "Deploy"}
{deploying ? (deployMode === "update" ? "Updating..." : "Deploying...") : (deployMode === "update" ? "Update" : "Deploy")}
</button>
</div>
</div>

View File

@@ -120,7 +120,6 @@ export default function MelodyDetail() {
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [showPlayback, setShowPlayback] = useState(false);
const [showBinaryView, setShowBinaryView] = useState(false);
const [offlineSaving, setOfflineSaving] = useState(false);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
@@ -192,31 +191,6 @@ export default function MelodyDetail() {
}
};
const handleToggleAvailableOffline = async (nextValue) => {
if (!canEdit || !melody) return;
setOfflineSaving(true);
setError("");
try {
const body = {
information: { ...(melody.information || {}), available_offline: nextValue },
default_settings: melody.default_settings || {},
type: melody.type || "all",
uid: melody.uid || "",
pid: melody.pid || "",
metadata: melody.metadata || {},
};
if (melody.url) body.url = melody.url;
await api.put(`/melodies/${id}`, body);
setMelody((prev) => ({
...prev,
information: { ...(prev?.information || {}), available_offline: nextValue },
}));
} catch (err) {
setError(err.message);
} finally {
setOfflineSaving(false);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
@@ -345,6 +319,26 @@ export default function MelodyDetail() {
)}
</div>
{info.outdated_archetype && (
<div
className="flex items-start gap-3 rounded-md px-4 py-3 text-sm border mb-6"
style={{
backgroundColor: "rgba(245,158,11,0.08)",
borderColor: "rgba(245,158,11,0.35)",
color: "#f59e0b",
}}
>
<svg className="mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>
<strong>Outdated Archetype</strong> The archetype assigned to this melody has been changed or removed.
Re-assign an archetype in the editor to update and clear this warning.
</span>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
{/* Left column */}
<div className="space-y-6">
@@ -511,20 +505,6 @@ export default function MelodyDetail() {
<h2 className="ui-section-card__title">Files</h2>
</div>
<dl className="space-y-4">
<Field label="Available as Built-In">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(info.available_offline)}
disabled={!canEdit || offlineSaving}
onChange={(e) => handleToggleAvailableOffline(e.target.checked)}
className="h-4 w-4 rounded"
/>
<span style={{ color: "var(--text-secondary)" }}>
{info.available_offline ? "Enabled" : "Disabled"}
</span>
</label>
</Field>
<Field label="Binary File">
{(() => {
// Common source of truth: assigned archetype binary first, then melody URL, then uploaded file URL.

View File

@@ -32,7 +32,6 @@ const defaultInfo = {
steps: 0,
color: "",
isTrueRing: false,
available_offline: false,
previewURL: "",
};
@@ -757,18 +756,6 @@ export default function MelodyForm() {
<h2 className="ui-section-card__title">Files</h2>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="available-offline"
checked={Boolean(information.available_offline)}
onChange={(e) => updateInfo("available_offline", e.target.checked)}
className="h-4 w-4 rounded"
/>
<label htmlFor="available-offline" className="text-sm font-medium" style={labelStyle}>
Available as Built-In
</label>
</div>
<div>
<label className="ui-form-label">Binary File (.bsm)</label>
{(() => {
@@ -1024,6 +1011,7 @@ export default function MelodyForm() {
if (archetype.steps != null) updateInfo("steps", archetype.steps);
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
updateInfo("outdated_archetype", false);
if (isEdit) {
loadMelody();
} else {
@@ -1059,6 +1047,7 @@ export default function MelodyForm() {
if (archetype.steps != null) updateInfo("steps", archetype.steps);
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
updateInfo("outdated_archetype", false);
if (isEdit) {
loadMelody();
} else {

View File

@@ -40,7 +40,6 @@ const ALL_COLUMNS = [
{ key: "pauseDuration", label: "Pause", defaultOn: false },
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
{ key: "builtIn", label: "Built-in", defaultOn: false },
{ key: "binaryFile", label: "Binary File", defaultOn: false },
{ key: "dateCreated", label: "Date Created", defaultOn: false },
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
@@ -300,57 +299,6 @@ function formatHex16(value) {
return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
}
function buildOfflineCppCode(rows) {
const selected = (rows || []).filter((row) => Boolean(row?.information?.available_offline));
const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
if (selected.length === 0) {
return `// Generated: ${generatedAt}\n// No melodies marked as built-in.\n`;
}
const arrays = [];
const libraryEntries = [];
for (const row of selected) {
const info = row?.information || {};
const displayName = getLocalizedValue(info.name, "en", getLocalizedValue(info.name, "en", "Untitled Melody"));
const uid = row?.uid || "";
const symbol = `melody_builtin_${toSafeCppSymbol(uid || displayName)}`;
const steps = parseArchetypeCsv(info.archetype_csv);
const stepCount = Number(info.steps || 0);
arrays.push(`// Melody: ${escapeCppString(displayName)} | UID: ${escapeCppString(uid || "missing_uid")}`);
arrays.push(`const uint16_t PROGMEM ${symbol}[] = {`);
if (steps.length === 0) {
arrays.push(" // No archetype_csv step data found");
} else {
for (let i = 0; i < steps.length; i += 8) {
const chunk = steps.slice(i, i + 8).map(formatHex16).join(", ");
arrays.push(` ${chunk}${i + 8 < steps.length ? "," : ""}`);
}
}
arrays.push("};");
arrays.push("");
libraryEntries.push(" {");
libraryEntries.push(` "${escapeCppString(displayName)}",`);
libraryEntries.push(` "${escapeCppString(uid || toSafeCppSymbol(displayName))}",`);
libraryEntries.push(` ${symbol},`);
libraryEntries.push(` ${stepCount > 0 ? stepCount : steps.length}`);
libraryEntries.push(" }");
}
return [
`// Generated: ${generatedAt}`,
"",
...arrays,
"// --- Add or replace your MELODY_LIBRARY[] with this: ---",
"const MelodyInfo MELODY_LIBRARY[] = {",
libraryEntries.map((entry, idx) => `${entry}${idx < libraryEntries.length - 1 ? "," : ""}`).join("\n"),
"};",
"",
].join("\n");
}
export default function MelodyList() {
const [melodies, setMelodies] = useState([]);
@@ -372,8 +320,6 @@ export default function MelodyList() {
const [actionLoading, setActionLoading] = useState(null);
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
const [showOfflineModal, setShowOfflineModal] = useState(false);
const [builtInSavingId, setBuiltInSavingId] = useState(null);
const [viewRow, setViewRow] = useState(null);
const [builtMap, setBuiltMap] = useState({});
const [pageSize, setPageSize] = useState(20);
@@ -606,36 +552,6 @@ export default function MelodyList() {
setViewRow(row);
};
const updateBuiltInState = async (e, row, nextValue) => {
e.stopPropagation();
if (!canEdit) return;
setBuiltInSavingId(row.id);
setError("");
try {
const body = {
information: { ...(row.information || {}), available_offline: nextValue },
default_settings: row.default_settings || {},
type: row.type || "all",
uid: row.uid || "",
pid: row.pid || "",
metadata: row.metadata || {},
};
if (row.url) body.url = row.url;
await api.put(`/melodies/${row.id}`, body);
setMelodies((prev) =>
prev.map((m) =>
m.id === row.id
? { ...m, information: { ...(m.information || {}), available_offline: nextValue } }
: m
)
);
} catch (err) {
setError(err.message);
} finally {
setBuiltInSavingId(null);
}
};
const toggleCreator = (creator) => {
setCreatedByFilter((prev) =>
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
@@ -695,16 +611,11 @@ export default function MelodyList() {
? displayRows.slice((safePage - 1) * pageSize, safePage * pageSize)
: displayRows;
const offlineTaggedCount = useMemo(
() => displayRows.filter((row) => Boolean(row?.information?.available_offline)).length,
[displayRows]
);
const hasAnyFilter = Boolean(
search || typeFilter || toneFilter || statusFilter || createdByFilter.length > 0
);
const offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]);
const handleSortClick = (columnKey) => {
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
if (!nextSortKey) return;
@@ -729,19 +640,30 @@ export default function MelodyList() {
const metadata = row.metadata || {};
switch (key) {
case "status":
case "status": {
const isOutdated = Boolean(info.outdated_archetype);
return (
<span
className="px-2 py-0.5 text-xs font-semibold rounded-full"
style={
row.status === "published"
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
}
>
{row.status === "published" ? "Live" : "Draft"}
</span>
<div className="flex flex-col items-start gap-1">
<span
className="px-2 py-0.5 text-xs font-semibold rounded-full"
style={
row.status === "published"
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
}
>
{row.status === "published" ? "Live" : "Draft"}
</span>
{isOutdated && (
<span className="px-1.5 py-0.5 text-xs rounded-full font-medium"
style={{ backgroundColor: "rgba(245,158,11,0.15)", color: "#f59e0b", border: "1px solid rgba(245,158,11,0.3)" }}
title="This melody's archetype has been changed or removed. Re-assign an archetype to clear this.">
Outdated
</span>
)}
</div>
);
}
case "color":
return info.color ? (
<span
@@ -754,11 +676,14 @@ export default function MelodyList() {
);
case "name": {
const description = getLocalizedValue(info.description, displayLang) || "-";
const isOutdated = Boolean(info.outdated_archetype);
return (
<div>
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
{getDisplayName(info.name)}
</span>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium" style={{ color: isOutdated ? "#f59e0b" : "var(--text-heading)" }}>
{getDisplayName(info.name)}
</span>
</div>
{isVisible("description") && (
<p
className="text-xs mt-0.5 truncate max-w-xs"
@@ -928,36 +853,6 @@ export default function MelodyList() {
) : (
"-"
);
case "builtIn": {
const enabled = Boolean(info.available_offline);
const saving = builtInSavingId === row.id;
return (
<button
type="button"
onClick={(e) => updateBuiltInState(e, row, !enabled)}
disabled={!canEdit || saving}
className="inline-flex items-center gap-2 cursor-pointer disabled:opacity-50"
style={{ background: "none", border: "none", padding: 0, color: "var(--text-secondary)" }}
title={canEdit ? "Click to toggle built-in availability" : "Built-in availability"}
>
<span
className="w-4 h-4 rounded-full border inline-flex items-center justify-center"
style={{
borderColor: enabled ? "rgba(34,197,94,0.7)" : "var(--border-primary)",
backgroundColor: enabled ? "rgba(34,197,94,0.15)" : "transparent",
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: enabled ? "#22c55e" : "transparent" }}
/>
</span>
<span className="text-xs" style={{ color: enabled ? "var(--success-text)" : "var(--text-muted)" }}>
{enabled ? "Yes" : "No"}
</span>
</button>
);
}
case "binaryFile": {
const resolved = resolveEffectiveBinary(row);
const binaryUrl = resolved.url;
@@ -1068,23 +963,9 @@ export default function MelodyList() {
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
{hasAnyFilter
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length} | ${offlineTaggedCount} offline-tagged`
: `Showing all (${allMelodyCount || melodies.length}) | ${offlineTaggedCount} tagged for Offline`}
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length}`
: `Showing all (${allMelodyCount || melodies.length})`}
</span>
{canEdit && (
<button
type="button"
onClick={() => setShowOfflineModal(true)}
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer whitespace-nowrap"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
backgroundColor: "var(--bg-card)",
}}
>
Build Offline List
</button>
)}
{canEdit && (
<button
onClick={() => navigate("/melodies/new")}
@@ -1427,67 +1308,6 @@ export default function MelodyList() {
onCancel={() => setUnpublishTarget(null)}
/>
{showOfflineModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
onClick={() => setShowOfflineModal(false)}
>
<div
className="w-full max-w-5xl max-h-[85vh] rounded-lg border shadow-xl flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Offline Built-In Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Includes melodies where Built-in = Yes
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(offlineCode);
} catch {
// ignore
}
}}
className="px-3 py-1.5 text-xs rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
Copy
</button>
<button
type="button"
onClick={() => setShowOfflineModal(false)}
className="px-3 py-1.5 text-xs rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
Close
</button>
</div>
</div>
<div className="p-5 overflow-auto">
<pre
className="text-xs rounded-lg p-4 overflow-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
border: "1px solid var(--border-primary)",
maxHeight: "62vh",
whiteSpace: "pre",
}}
>
{offlineCode}
</pre>
</div>
</div>
</div>
)}
<BinaryTableModal
open={!!viewRow}
melody={viewRow || null}

View File

@@ -1,7 +1,9 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, Link } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
import PlaybackModal from "../PlaybackModal";
import { getLocalizedValue } from "../melodyUtils";
const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
@@ -68,12 +70,11 @@ export default function ArchetypeForm() {
const [name, setName] = useState("");
const [pid, setPid] = useState("");
const [steps, setSteps] = useState("");
const [isBuiltin, setIsBuiltin] = useState(false);
const [savedPid, setSavedPid] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [buildingBinary, setBuildingBinary] = useState(false);
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
const [error, setError] = useState("");
const [successMsg, setSuccessMsg] = useState("");
@@ -86,10 +87,19 @@ export default function ArchetypeForm() {
const [showDelete, setShowDelete] = useState(false);
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
const [assignedCount, setAssignedCount] = useState(0);
const [assignedMelodies, setAssignedMelodies] = useState([]); // [{id, nameRaw}]
const [loadingAssigned, setLoadingAssigned] = useState(false);
const [primaryLang, setPrimaryLang] = useState("en");
// Playback
const [showPlayback, setShowPlayback] = useState(false);
// currentBuiltMelody: the live archetype object (for playback + binary_url)
const [currentBuiltMelody, setCurrentBuiltMelody] = useState(null);
const codeRef = useRef(null);
useEffect(() => {
api.get("/settings/melody").then((ms) => setPrimaryLang(ms.primary_language || "en")).catch(() => {});
if (isEdit) loadArchetype();
}, [id]);
@@ -100,12 +110,18 @@ export default function ArchetypeForm() {
setName(data.name || "");
setPid(data.pid || "");
setSteps(data.steps || "");
setIsBuiltin(Boolean(data.is_builtin));
setSavedPid(data.pid || "");
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setProgmemCode(data.progmem_code || "");
setAssignedCount(data.assigned_melody_ids?.length || 0);
setHasUnsavedChanges(false);
setCurrentBuiltMelody(data);
// Load assigned melody details
if (data.assigned_melody_ids?.length > 0) {
fetchAssignedMelodies(data.assigned_melody_ids);
}
} catch (err) {
setError(err.message);
} finally {
@@ -113,14 +129,25 @@ export default function ArchetypeForm() {
}
};
const fetchAssignedMelodies = async (ids) => {
setLoadingAssigned(true);
try {
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)));
const details = results
.filter((r) => r.status === "fulfilled" && r.value)
.map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }));
setAssignedMelodies(details);
} catch { /* best-effort */ }
finally { setLoadingAssigned(false); }
};
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v.toLowerCase().replace(/[^a-z0-9_]/g, "")); setHasUnsavedChanges(true); };
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
const handleSave = async () => {
if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
@@ -130,22 +157,27 @@ export default function ArchetypeForm() {
try {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim(), is_builtin: isBuiltin };
if (isEdit) {
await api.put(`/builder/melodies/${id}`, body);
// PUT triggers auto-rebuild + outdated flagging on the backend
const updated = await api.put(`/builder/melodies/${id}`, body);
setSavedPid(pid.trim());
setBinaryBuilt(Boolean(updated.binary_path));
setBinaryUrl(updated.binary_url || null);
setProgmemCode(updated.progmem_code || "");
setCurrentBuiltMelody(updated);
setHasUnsavedChanges(false);
setSuccessMsg("Saved.");
setSuccessMsg("Rebuilt and saved successfully.");
} else {
// POST triggers auto-build on the backend
const created = await api.post("/builder/melodies", body);
navigate(`/melodies/archetypes/${created.id}`, { replace: true });
navigate(`/melodies/archetypes/${created.id}`);
}
} catch (err) {
setError(err.message);
@@ -154,41 +186,6 @@ export default function ArchetypeForm() {
}
};
const handleBuildBinary = async () => {
if (!isEdit) { setError("Save the archetype first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
setBuildingBinary(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-binary`);
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setSuccessMsg("Binary built successfully.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBinary(false);
}
};
const handleBuildBuiltin = async () => {
if (!isEdit) { setError("Save the archetype first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
setBuildingBuiltin(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
setProgmemCode(data.progmem_code || "");
setSuccessMsg("PROGMEM code generated.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBuiltin(false);
}
};
const handleCopy = () => {
if (!progmemCode) return;
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
@@ -205,12 +202,18 @@ export default function ArchetypeForm() {
}
};
// Build a "virtual" builtMelody for playback using current steps (even if unsaved)
const playbackBuiltMelody = currentBuiltMelody
? { ...currentBuiltMelody, steps: steps }
: { id: "preview", name: name || "Preview", pid: pid || "", steps, binary_url: null };
if (loading) {
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
return (
<div>
{/* Header row */}
<div className="flex items-center justify-between mb-6">
<div>
<button onClick={() => navigate("/melodies/archetypes")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
@@ -220,30 +223,62 @@ export default function ArchetypeForm() {
{isEdit ? "Edit Archetype" : "New Archetype"}
</h1>
</div>
<div className="flex gap-3">
<div className="flex items-center gap-2">
{/* Section 1: Built-in + Play */}
<button
onClick={() => setIsBuiltin((v) => { setHasUnsavedChanges(true); return !v; })}
className="px-3 py-2 text-sm rounded-md transition-colors flex items-center gap-1.5"
style={isBuiltin
? { backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa", border: "1px solid rgba(59,130,246,0.3)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }
}
title="Toggle built-in flag"
>
{isBuiltin ? "Built-in ✓" : "Built-in"}
</button>
<button
onClick={() => setShowPlayback(true)}
disabled={!steps.trim()}
title="Preview current steps"
className="px-3 py-2 text-sm rounded-md transition-colors disabled:opacity-40 flex items-center gap-1.5"
style={{ backgroundColor: "var(--bg-card-hover)", color: "#4ade80", border: "1px solid rgba(74,222,128,0.3)" }}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flexShrink: 0 }}>
<path d="M3 5C3 2.23858 5.23858 0 8 0C10.7614 0 13 2.23858 13 5V8L15 10V12H1V10L3 8V5Z" fill="currentColor"/>
<path d="M7.99999 16C6.69378 16 5.58254 15.1652 5.1707 14H10.8293C10.4175 15.1652 9.30621 16 7.99999 16Z" fill="currentColor"/>
</svg> Play
</button>
{/* Divider */}
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)", margin: "0 2px" }} />
{/* Section 2: Cancel + Delete */}
<button
onClick={() => navigate("/melodies/archetypes")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Cancel
</button>
{isEdit && (
<button
onClick={() => setShowDelete(true)}
<button onClick={() => setShowDelete(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
Delete
</button>
)}
{/* Divider */}
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)", margin: "0 2px" }} />
{/* Section 3: Save */}
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
{saving ? "Saving..." : isEdit ? "Rebuild and Save" : "Create and Build"}
</button>
</div>
</div>
@@ -260,6 +295,7 @@ export default function ArchetypeForm() {
)}
<div className="space-y-6">
{/* ── Archetype Info ────────────────────────────────────────── */}
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Archetype Info</h2>
@@ -267,12 +303,14 @@ export default function ArchetypeForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="ui-form-label">Name *</label>
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)}
placeholder="e.g. Doksologia_3k" className={inputClass} />
</div>
<div>
<label className="ui-form-label">PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)}
placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Lowercase letters, numbers, and underscores only. Must be unique.</p>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
@@ -294,13 +332,43 @@ export default function ArchetypeForm() {
</div>
</section>
{/* ── Melodies using this Archetype ─────────────────────────── */}
{isEdit && (
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Build</h2>
<h2 className="ui-section-card__title">Melodies Using This Archetype</h2>
<span className="text-xs px-2 py-0.5 rounded-full ml-2"
style={{ backgroundColor: "rgba(59,130,246,0.12)", color: "#60a5fa" }}>
{assignedCount}
</span>
</div>
<p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output.
{loadingAssigned ? (
<p className="text-sm" style={mutedStyle}>Loading...</p>
) : assignedMelodies.length === 0 ? (
<p className="text-sm" style={mutedStyle}>None</p>
) : (
<div className="space-y-1 max-h-44 overflow-y-auto">
{assignedMelodies.slice(0, 20).map((m) => (
<Link key={m.id} to={`/melodies/${m.id}`} target="_blank" rel="noopener noreferrer"
className="flex items-center justify-between px-3 py-2 rounded-lg border transition-colors hover:bg-[var(--bg-card-hover)]"
style={{ borderColor: "var(--border-primary)", color: "var(--text-heading)", textDecoration: "none" }}>
<span className="text-sm font-medium">{getLocalizedValue(m.nameRaw, primaryLang, m.id)}</span>
<span className="text-xs" style={{ color: "var(--accent)" }}>Open </span>
</Link>
))}
</div>
)}
</section>
)}
{/* ── Build Output ─────────────────────────────────────────── */}
{isEdit && (
<section className="ui-section-card">
<div className="ui-section-card__title-row">
<h2 className="ui-section-card__title">Build Output</h2>
</div>
<p className="text-sm mb-3" style={mutedStyle}>
Binary and PROGMEM code are auto-rebuilt on every save.
{hasUnsavedChanges && (
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> You have unsaved changes.</span>
)}
@@ -308,94 +376,64 @@ export default function ArchetypeForm() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Binary */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="rounded-lg p-4 border space-y-2" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
</div>
{binaryBuilt && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Built
</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${binaryBuilt ? "" : "opacity-50"}`}
style={binaryBuilt
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{binaryBuilt ? "Built" : "Not built"}
</span>
</div>
<button
onClick={handleBuildBinary}
disabled={buildingBinary || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
</button>
{binaryUrl && (
<button
type="button"
<button type="button"
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
className="block w-full text-center text-xs underline cursor-pointer"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}>
Download {savedPid}.bsm
</button>
)}
</div>
{/* Builtin Code */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
{/* PROGMEM */}
<div className="rounded-lg p-4 border space-y-2" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
</div>
{progmemCode && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Generated
</span>
)}
<span className={`px-2 py-0.5 text-xs rounded-full ${progmemCode ? "" : "opacity-50"}`}
style={progmemCode
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{progmemCode ? "Generated" : "Not generated"}
</span>
</div>
<button
onClick={handleBuildBuiltin}
disabled={buildingBuiltin || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
</button>
</div>
</div>
{progmemCode && (
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between px-4 py-2 border-b"
style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
PROGMEM C Code copy into your firmware
</span>
<button
onClick={handleCopy}
className="text-xs px-3 py-1 rounded transition-colors"
<button onClick={handleCopy} className="text-xs px-3 py-1 rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
}}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre
ref={codeRef}
className="p-4 text-xs overflow-x-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontFamily: "monospace",
whiteSpace: "pre",
maxHeight: "400px",
overflowY: "auto",
}}
>
<pre ref={codeRef} className="p-4 text-xs overflow-x-auto"
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", fontFamily: "monospace", whiteSpace: "pre", maxHeight: "400px", overflowY: "auto" }}>
{progmemCode}
</pre>
</div>
@@ -405,7 +443,7 @@ export default function ArchetypeForm() {
{!isEdit && (
<div className="ui-section-card text-sm" style={{ color: "var(--text-muted)" }}>
Build actions (Binary + PROGMEM Code) will be available after saving.
Binary and PROGMEM code will be generated automatically when you click "Create and Build".
</div>
)}
</div>
@@ -413,27 +451,23 @@ export default function ArchetypeForm() {
{/* Delete: two-stage if assigned */}
{showDelete && !deleteWarningConfirmed && assignedCount > 0 && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<div className="rounded-md p-3 border text-sm"
style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<strong>"{name}"</strong> is currently assigned to{" "}
<strong>{assignedCount} {assignedCount === 1 ? "melody" : "melodies"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
Deleting it will flag those melodies as <strong>outdated</strong>.
</div>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3">
<button
onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
<button onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={() => setDeleteWarningConfirmed(true)}
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Cancel</button>
<button onClick={() => setDeleteWarningConfirmed(true)}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
Yes, Delete Anyway
</button>
</div>
@@ -448,6 +482,15 @@ export default function ArchetypeForm() {
onConfirm={handleDelete}
onCancel={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
/>
<PlaybackModal
open={showPlayback}
melody={null}
builtMelody={playbackBuiltMelody}
files={null}
archetypeCsv={steps}
onClose={() => setShowPlayback(false)}
/>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -121,6 +121,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, curr
archetype_csv: csv,
steps: stepCount,
totalNotes,
outdated_archetype: false,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,

View File

@@ -69,6 +69,7 @@ export default function SelectArchetypeModal({ open, melodyId, currentMelody, cu
archetype_csv: csv,
steps,
totalNotes,
outdated_archetype: false,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,

View File

@@ -0,0 +1,133 @@
import { useState, useEffect } from "react";
import api from "../api/client";
export default function PublicFeaturesSettings() {
const [settings, setSettings] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
useEffect(() => {
api.get("/settings/public-features")
.then(setSettings)
.catch((e) => setError(e.message || "Failed to load settings."))
.finally(() => setLoading(false));
}, []);
const handleToggle = async (key, value) => {
setError("");
setSuccess("");
setSaving(true);
try {
const updated = await api.put("/settings/public-features", { [key]: value });
setSettings(updated);
setSuccess("Settings saved.");
setTimeout(() => setSuccess(""), 3000);
} catch (e) {
setError(e.message || "Failed to save settings.");
} finally {
setSaving(false);
}
};
return (
<div>
{/* Page header */}
<div className="mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Public Features
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
Control which public-facing pages are accessible to end-users without requiring a login.
</p>
</div>
{/* Error / success feedback */}
{error && (
<div
className="text-sm rounded-md p-3 border mb-4"
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
>
{error}
</div>
)}
{success && (
<div
className="text-sm rounded-md p-3 border mb-4"
style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}
>
{success}
</div>
)}
{loading ? (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading</p>
) : settings ? (
<div className="space-y-3">
<FeatureToggleRow
title="CloudFlash"
description="Allows end-users to flash their own devices via USB directly from the browser. When disabled, the CloudFlash page is inaccessible and displays a maintenance message."
enabled={settings.cloudflash_enabled}
saving={saving}
onToggle={(val) => handleToggle("cloudflash_enabled", val)}
url="/cloudflash"
/>
</div>
) : null}
</div>
);
}
function FeatureToggleRow({ title, description, enabled, saving, onToggle, url }) {
return (
<div
className="rounded-lg border p-5"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-start justify-between gap-6">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>
{title}
</span>
<span
className="px-2 py-0.5 text-xs rounded-full font-medium"
style={
enabled
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
}
>
{enabled ? "Live" : "Disabled"}
</span>
</div>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{description}
</p>
{url && (
<p className="text-xs mt-1.5 font-mono" style={{ color: "var(--text-link)" }}>
{url}
</p>
)}
</div>
{/* Toggle switch */}
<button
type="button"
disabled={saving}
onClick={() => onToggle(!enabled)}
className="flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
style={{ backgroundColor: enabled ? "var(--accent)" : "var(--border-primary)" }}
aria-checked={enabled}
role="switch"
>
<span
className="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
style={{ transform: enabled ? "translateX(20px)" : "translateX(4px)" }}
/>
</button>
</div>
</div>
);
}