CODEX - Moved the upload binary to the Archetype

This commit is contained in:
2026-02-23 11:49:50 +02:00
parent be0b3a5a5a
commit 9195f143a2
4 changed files with 226 additions and 52 deletions

View File

@@ -556,6 +556,10 @@ export default function MelodyDetail() {
const handleDownload = async (e) => { const handleDownload = async (e) => {
e.preventDefault(); e.preventDefault();
if (binaryUrl.startsWith("http")) {
window.open(binaryUrl, "_blank", "noopener,noreferrer");
return;
}
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
let res = null; let res = null;

View File

@@ -92,7 +92,6 @@ export default function MelodyForm() {
const [pid, setPid] = useState(""); const [pid, setPid] = useState("");
const [melodyStatus, setMelodyStatus] = useState("draft"); const [melodyStatus, setMelodyStatus] = useState("draft");
const [binaryFile, setBinaryFile] = useState(null);
const [previewFile, setPreviewFile] = useState(null); const [previewFile, setPreviewFile] = useState(null);
const [existingFiles, setExistingFiles] = useState({}); const [existingFiles, setExistingFiles] = useState({});
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -120,7 +119,6 @@ export default function MelodyForm() {
const [builtMelody, setBuiltMelody] = useState(null); const [builtMelody, setBuiltMelody] = useState(null);
const [assignedBinaryName, setAssignedBinaryName] = useState(null); const [assignedBinaryName, setAssignedBinaryName] = useState(null);
const [assignedBinaryPid, setAssignedBinaryPid] = useState(null); const [assignedBinaryPid, setAssignedBinaryPid] = useState(null);
const binaryInputRef = useRef(null);
const previewInputRef = useRef(null); const previewInputRef = useRef(null);
// Metadata / Admin Notes // Metadata / Admin Notes
@@ -215,6 +213,20 @@ export default function MelodyForm() {
} }
}; };
const getEffectiveBinary = () => {
const effectiveUrl = normalizeFileUrl(
(builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
url ||
existingFiles.binary_url ||
null
);
const fixedNameFromPid = builtMelody?.pid || assignedBinaryPid || pid || null;
const effectiveName = fixedNameFromPid
? `${fixedNameFromPid}.bsm`
: resolveFilename(effectiveUrl, "binary.bsm");
return { effectiveUrl, effectiveName };
};
const downloadExistingFile = async (fileUrl, fallbackName, e) => { const downloadExistingFile = async (fileUrl, fallbackName, e) => {
e?.preventDefault?.(); e?.preventDefault?.();
if (!fileUrl) return; if (!fileUrl) return;
@@ -233,7 +245,8 @@ export default function MelodyForm() {
} }
} }
if ((!res || !res.ok) && fileUrl.startsWith("http")) { if ((!res || !res.ok) && fileUrl.startsWith("http")) {
res = await fetch(fileUrl); window.open(fileUrl, "_blank", "noopener,noreferrer");
return;
} }
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
const blob = await res.blob(); const blob = await res.blob();
@@ -282,9 +295,8 @@ export default function MelodyForm() {
}; };
const uploadFiles = async (melodyId) => { const uploadFiles = async (melodyId) => {
if (binaryFile || previewFile) { if (previewFile) {
setUploading(true); setUploading(true);
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile); if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
setUploading(false); setUploading(false);
} }
@@ -749,16 +761,9 @@ export default function MelodyForm() {
</label> </label>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm)</label>
{(() => { {(() => {
const binaryUrl = normalizeFileUrl( const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
(builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
url ||
existingFiles.binary_url ||
null
);
const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download");
const binaryName = resolveFilename(binaryUrl, fallback);
return ( return (
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}> <div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
{binaryUrl ? ( {binaryUrl ? (
@@ -792,22 +797,7 @@ export default function MelodyForm() {
</div> </div>
); );
})()} })()}
<input
ref={binaryInputRef}
type="file"
accept=".bin,.bsm"
onChange={(e) => setBinaryFile(e.target.files[0] || null)}
className="hidden"
/>
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<button
type="button"
onClick={() => binaryInputRef.current?.click()}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Choose Binary File
</button>
<button <button
type="button" type="button"
onClick={async () => { onClick={async () => {
@@ -851,11 +841,6 @@ export default function MelodyForm() {
Build on the Fly Build on the Fly
</button> </button>
</div> </div>
{binaryFile && (
<p className="text-xs mt-1" style={mutedStyle}>
selected: {binaryFile.name}
</p>
)}
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
@@ -968,7 +953,7 @@ export default function MelodyForm() {
open={showPlayback} open={showPlayback}
melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }} melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody} builtMelody={builtMelody}
files={{ binary_url: normalizeFileUrl(existingFiles.binary_url || url || null) }} files={{ binary_url: getEffectiveBinary().effectiveUrl }}
archetypeCsv={information.archetype_csv || null} archetypeCsv={information.archetype_csv || null}
onClose={() => setShowPlayback(false)} onClose={() => setShowPlayback(false)}
/> />
@@ -976,7 +961,7 @@ export default function MelodyForm() {
open={showBinaryView} open={showBinaryView}
melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }} melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody} builtMelody={builtMelody}
files={{ binary_url: normalizeFileUrl(existingFiles.binary_url || url || null) }} files={{ binary_url: getEffectiveBinary().effectiveUrl }}
archetypeCsv={information.archetype_csv || null} archetypeCsv={information.archetype_csv || null}
onClose={() => setShowBinaryView(false)} onClose={() => setShowBinaryView(false)}
/> />
@@ -1020,9 +1005,11 @@ export default function MelodyForm() {
Promise.all([ Promise.all([
api.get(`/melodies/${mid}/files`), api.get(`/melodies/${mid}/files`),
api.get(`/melodies/${mid}`), api.get(`/melodies/${mid}`),
]).then(([files, m]) => { api.get(`/builder/melodies/for-melody/${mid}`),
]).then(([files, m, bm]) => {
setExistingFiles(files); setExistingFiles(files);
if (m.url) setUrl(m.url); if (m.url) setUrl(m.url);
setBuiltMelody(bm || null);
}).catch(() => {}); }).catch(() => {});
} }
} }
@@ -1052,9 +1039,11 @@ export default function MelodyForm() {
Promise.all([ Promise.all([
api.get(`/melodies/${mid}/files`), api.get(`/melodies/${mid}/files`),
api.get(`/melodies/${mid}`), api.get(`/melodies/${mid}`),
]).then(([files, m]) => { api.get(`/builder/melodies/for-melody/${mid}`),
]).then(([files, m, bm]) => {
setExistingFiles(files); setExistingFiles(files);
if (m.url) setUrl(m.url); if (m.url) setUrl(m.url);
setBuiltMelody(bm || null);
}).catch(() => {}); }).catch(() => {});
} }
} }

View File

@@ -589,6 +589,14 @@ export default function MelodyList() {
}); });
}, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps }, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps
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 offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]);
const handleSortClick = (columnKey) => { const handleSortClick = (columnKey) => {
@@ -943,7 +951,7 @@ export default function MelodyList() {
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border"; const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
return ( return (
<div className="w-full min-w-0"> <div className="w-full min-w-0 overflow-x-hidden">
<div className="flex items-center justify-between mb-6 w-full"> <div className="flex items-center justify-between mb-6 w-full">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melodies</h1> <h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melodies</h1>
{canEdit && ( {canEdit && (
@@ -962,7 +970,7 @@ export default function MelodyList() {
onSearch={setSearch} onSearch={setSearch}
placeholder="Search by name, description, or tags..." placeholder="Search by name, description, or tags..."
/> />
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center w-full">
<select <select
value={typeFilter} value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)} onChange={(e) => setTypeFilter(e.target.value)}
@@ -1131,12 +1139,19 @@ export default function MelodyList() {
)} )}
</div> </div>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3 flex-nowrap">
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
<span className="inline-block max-w-[48vw] overflow-hidden text-ellipsis align-bottom">
{hasAnyFilter
? `Filtered - Showing ${displayRows.length} / ${melodies.length} Melodies | ${offlineTaggedCount} Melodies tagged for Offline`
: `Showing all (${melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`}
</span>
</span>
{canEdit && ( {canEdit && (
<button <button
type="button" type="button"
onClick={() => setShowOfflineModal(true)} onClick={() => setShowOfflineModal(true)}
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer" className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer whitespace-nowrap"
style={{ style={{
borderColor: "var(--border-primary)", borderColor: "var(--border-primary)",
color: "var(--text-secondary)", color: "var(--text-secondary)",
@@ -1146,9 +1161,6 @@ export default function MelodyList() {
Build Offline List Build Offline List
</button> </button>
)} )}
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -23,6 +23,158 @@ function copyText(text, onSuccess) {
} }
} }
function stepValueToNotation(stepValue) {
const v = Number(stepValue || 0) & 0xffff;
if (!v) return "0";
const bells = [];
for (let bit = 0; bit < 16; bit++) {
if (v & (1 << bit)) bells.push(String(bit + 1));
}
return bells.join("+");
}
async function decodeBinaryToStepsString(file) {
const buf = await file.arrayBuffer();
const view = new DataView(buf);
const values = [];
for (let i = 0; i + 1 < buf.byteLength; i += 2) {
values.push(view.getUint16(i, false));
}
return values.map(stepValueToNotation).join(",");
}
function UploadArchetypeModal({ open, existingArchetypes, onClose, onUploaded }) {
const [file, setFile] = useState(null);
const [name, setName] = useState("");
const [pid, setPid] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) {
setFile(null);
setName("");
setPid("");
setSubmitting(false);
setError("");
}
}, [open]);
if (!open) return null;
const submit = async () => {
setError("");
if (!file) { setError("Please choose a .bin or .bsm file."); return; }
if (!name.trim()) { setError("Friendly name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
const dupName = (existingArchetypes || []).find((a) => a.name?.toLowerCase() === name.trim().toLowerCase());
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
const dupPid = (existingArchetypes || []).find((a) => a.pid?.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
setSubmitting(true);
try {
const steps = await decodeBinaryToStepsString(file);
const created = await api.post("/builder/melodies", {
name: name.trim(),
pid: pid.trim(),
steps,
});
await api.post(`/builder/melodies/${created.id}/build-binary`);
onUploaded?.();
onClose?.();
} catch (err) {
setError(err.message || "Failed to upload archetype.");
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.65)" }}
onClick={(e) => e.target === e.currentTarget && !submitting && onClose()}
>
<div
className="w-full max-w-md rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Upload Archetype</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>Upload .bin/.bsm and save as selectable archetype</p>
</div>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }} disabled={submitting}>&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
{error && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>Binary File (.bin/.bsm) *</label>
<input
type="file"
accept=".bin,.bsm,application/octet-stream"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="w-full px-3 py-2 rounded-md text-sm border"
disabled={submitting}
/>
{file && (
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>{file.name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>Friendly Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
placeholder="e.g. Doxology Festive Upload"
disabled={submitting}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>PID *</label>
<input
type="text"
value={pid}
onChange={(e) => setPid(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
placeholder="e.g. builtin_doxology_upload"
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button
onClick={onClose}
disabled={submitting}
className="px-4 py-2 text-sm rounded-md transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={submit}
disabled={submitting}
className="px-4 py-2 text-sm rounded-md transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{submitting ? "Uploading..." : "Upload Archetype"}
</button>
</div>
</div>
</div>
);
}
function CodeSnippetModal({ melody, onClose }) { function CodeSnippetModal({ melody, onClose }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
if (!melody) return null; if (!melody) return null;
@@ -127,6 +279,7 @@ export default function ArchetypeList() {
const [assignedModal, setAssignedModal] = useState(null); // { archetype, melodyDetails } const [assignedModal, setAssignedModal] = useState(null); // { archetype, melodyDetails }
const [loadingAssigned, setLoadingAssigned] = useState(false); const [loadingAssigned, setLoadingAssigned] = useState(false);
const [primaryLang, setPrimaryLang] = useState("en"); const [primaryLang, setPrimaryLang] = useState("en");
const [showUploadModal, setShowUploadModal] = useState(false);
useEffect(() => { useEffect(() => {
api.get("/settings/melody").then((ms) => setPrimaryLang(ms.primary_language || "en")).catch(() => {}); api.get("/settings/melody").then((ms) => setPrimaryLang(ms.primary_language || "en")).catch(() => {});
@@ -259,13 +412,22 @@ export default function ArchetypeList() {
Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code. Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.
</p> </p>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={() => navigate("/melodies/archetypes/new")} <button
className="px-4 py-2 text-sm rounded-md transition-colors" onClick={() => setShowUploadModal(true)}
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} className="px-4 py-2 text-sm rounded-md transition-colors"
> style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
+ Add Archetype >
</button> Upload Archetype
</button>
<button
onClick={() => navigate("/melodies/archetypes/new")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Add Archetype
</button>
</div>
</div> </div>
{error && ( {error && (
@@ -448,6 +610,13 @@ export default function ArchetypeList() {
primaryLang={primaryLang} primaryLang={primaryLang}
onClose={() => setAssignedModal(null)} onClose={() => setAssignedModal(null)}
/> />
<UploadArchetypeModal
open={showUploadModal}
existingArchetypes={archetypes}
onClose={() => setShowUploadModal(false)}
onUploaded={() => loadArchetypes()}
/>
</div> </div>
); );
} }