update: overhauled firmware ui. Added public flash page.
This commit is contained in:
@@ -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>
|
||||
|
||||
BIN
frontend/src/assets/logos/cloudflash_large.png
Normal file
BIN
frontend/src/assets/logos/cloudflash_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
frontend/src/assets/logos/cloudflash_small.png
Normal file
BIN
frontend/src/assets/logos/cloudflash_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1053
frontend/src/cloudflash/CloudFlashPage.jsx
Normal file
1053
frontend/src/cloudflash/CloudFlashPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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)" }}>✕</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)}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
133
frontend/src/settings/PublicFeaturesSettings.jsx
Normal file
133
frontend/src/settings/PublicFeaturesSettings.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user