CODEX - Moved the upload binary to the Archetype
This commit is contained in:
@@ -556,6 +556,10 @@ export default function MelodyDetail() {
|
||||
|
||||
const handleDownload = async (e) => {
|
||||
e.preventDefault();
|
||||
if (binaryUrl.startsWith("http")) {
|
||||
window.open(binaryUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem("access_token");
|
||||
let res = null;
|
||||
|
||||
@@ -92,7 +92,6 @@ export default function MelodyForm() {
|
||||
const [pid, setPid] = useState("");
|
||||
const [melodyStatus, setMelodyStatus] = useState("draft");
|
||||
|
||||
const [binaryFile, setBinaryFile] = useState(null);
|
||||
const [previewFile, setPreviewFile] = useState(null);
|
||||
const [existingFiles, setExistingFiles] = useState({});
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -120,7 +119,6 @@ export default function MelodyForm() {
|
||||
const [builtMelody, setBuiltMelody] = useState(null);
|
||||
const [assignedBinaryName, setAssignedBinaryName] = useState(null);
|
||||
const [assignedBinaryPid, setAssignedBinaryPid] = useState(null);
|
||||
const binaryInputRef = useRef(null);
|
||||
const previewInputRef = useRef(null);
|
||||
|
||||
// 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) => {
|
||||
e?.preventDefault?.();
|
||||
if (!fileUrl) return;
|
||||
@@ -233,7 +245,8 @@ export default function MelodyForm() {
|
||||
}
|
||||
}
|
||||
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}`);
|
||||
const blob = await res.blob();
|
||||
@@ -282,9 +295,8 @@ export default function MelodyForm() {
|
||||
};
|
||||
|
||||
const uploadFiles = async (melodyId) => {
|
||||
if (binaryFile || previewFile) {
|
||||
if (previewFile) {
|
||||
setUploading(true);
|
||||
if (binaryFile) await api.upload(`/melodies/${melodyId}/upload/binary`, binaryFile);
|
||||
if (previewFile) await api.upload(`/melodies/${melodyId}/upload/preview`, previewFile);
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -749,16 +761,9 @@ export default function MelodyForm() {
|
||||
</label>
|
||||
</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(
|
||||
(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);
|
||||
const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary();
|
||||
return (
|
||||
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
|
||||
{binaryUrl ? (
|
||||
@@ -792,22 +797,7 @@ export default function MelodyForm() {
|
||||
</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">
|
||||
<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
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
@@ -851,11 +841,6 @@ export default function MelodyForm() {
|
||||
Build on the Fly
|
||||
</button>
|
||||
</div>
|
||||
{binaryFile && (
|
||||
<p className="text-xs mt-1" style={mutedStyle}>
|
||||
selected: {binaryFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<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}
|
||||
melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }}
|
||||
builtMelody={builtMelody}
|
||||
files={{ binary_url: normalizeFileUrl(existingFiles.binary_url || url || null) }}
|
||||
files={{ binary_url: getEffectiveBinary().effectiveUrl }}
|
||||
archetypeCsv={information.archetype_csv || null}
|
||||
onClose={() => setShowPlayback(false)}
|
||||
/>
|
||||
@@ -976,7 +961,7 @@ export default function MelodyForm() {
|
||||
open={showBinaryView}
|
||||
melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }}
|
||||
builtMelody={builtMelody}
|
||||
files={{ binary_url: normalizeFileUrl(existingFiles.binary_url || url || null) }}
|
||||
files={{ binary_url: getEffectiveBinary().effectiveUrl }}
|
||||
archetypeCsv={information.archetype_csv || null}
|
||||
onClose={() => setShowBinaryView(false)}
|
||||
/>
|
||||
@@ -1020,9 +1005,11 @@ export default function MelodyForm() {
|
||||
Promise.all([
|
||||
api.get(`/melodies/${mid}/files`),
|
||||
api.get(`/melodies/${mid}`),
|
||||
]).then(([files, m]) => {
|
||||
api.get(`/builder/melodies/for-melody/${mid}`),
|
||||
]).then(([files, m, bm]) => {
|
||||
setExistingFiles(files);
|
||||
if (m.url) setUrl(m.url);
|
||||
setBuiltMelody(bm || null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1052,9 +1039,11 @@ export default function MelodyForm() {
|
||||
Promise.all([
|
||||
api.get(`/melodies/${mid}/files`),
|
||||
api.get(`/melodies/${mid}`),
|
||||
]).then(([files, m]) => {
|
||||
api.get(`/builder/melodies/for-melody/${mid}`),
|
||||
]).then(([files, m, bm]) => {
|
||||
setExistingFiles(files);
|
||||
if (m.url) setUrl(m.url);
|
||||
setBuiltMelody(bm || null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +589,14 @@ export default function MelodyList() {
|
||||
});
|
||||
}, [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 handleSortClick = (columnKey) => {
|
||||
@@ -943,7 +951,7 @@ export default function MelodyList() {
|
||||
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
|
||||
|
||||
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">
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melodies</h1>
|
||||
{canEdit && (
|
||||
@@ -962,7 +970,7 @@ export default function MelodyList() {
|
||||
onSearch={setSearch}
|
||||
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
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
@@ -1131,12 +1139,19 @@ export default function MelodyList() {
|
||||
)}
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
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={{
|
||||
borderColor: "var(--border-primary)",
|
||||
color: "var(--text-secondary)",
|
||||
@@ -1146,9 +1161,6 @@ export default function MelodyList() {
|
||||
Build Offline List
|
||||
</button>
|
||||
)}
|
||||
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}>×</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 }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
if (!melody) return null;
|
||||
@@ -127,6 +279,7 @@ export default function ArchetypeList() {
|
||||
const [assignedModal, setAssignedModal] = useState(null); // { archetype, melodyDetails }
|
||||
const [loadingAssigned, setLoadingAssigned] = useState(false);
|
||||
const [primaryLang, setPrimaryLang] = useState("en");
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<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 className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
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)" }}
|
||||
>
|
||||
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>
|
||||
|
||||
{error && (
|
||||
@@ -448,6 +610,13 @@ export default function ArchetypeList() {
|
||||
primaryLang={primaryLang}
|
||||
onClose={() => setAssignedModal(null)}
|
||||
/>
|
||||
|
||||
<UploadArchetypeModal
|
||||
open={showUploadModal}
|
||||
existingArchetypes={archetypes}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onUploaded={() => loadArchetypes()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user