diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx
index 5f1c6bc..5718f2b 100644
--- a/frontend/src/melodies/MelodyDetail.jsx
+++ b/frontend/src/melodies/MelodyDetail.jsx
@@ -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;
diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx
index 131a390..f3cd04f 100644
--- a/frontend/src/melodies/MelodyForm.jsx
+++ b/frontend/src/melodies/MelodyForm.jsx
@@ -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() {
-
+
{(() => {
- 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 (
{binaryUrl ? (
@@ -792,22 +797,7 @@ export default function MelodyForm() {
);
})()}
-
setBinaryFile(e.target.files[0] || null)}
- className="hidden"
- />
-
- {binaryFile && (
-
- selected: {binaryFile.name}
-
- )}
@@ -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(() => {});
}
}
diff --git a/frontend/src/melodies/MelodyList.jsx b/frontend/src/melodies/MelodyList.jsx
index 031ef7c..07520ad 100644
--- a/frontend/src/melodies/MelodyList.jsx
+++ b/frontend/src/melodies/MelodyList.jsx
@@ -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 (
-
+
Melodies
{canEdit && (
@@ -962,7 +970,7 @@ export default function MelodyList() {
onSearch={setSearch}
placeholder="Search by name, description, or tags..."
/>
-
+
-
+
+
+
+ {hasAnyFilter
+ ? `Filtered - Showing ${displayRows.length} / ${melodies.length} Melodies | ${offlineTaggedCount} Melodies tagged for Offline`
+ : `Showing all (${melodies.length}) Melodies | ${offlineTaggedCount} Melodies tagged for Offline`}
+
+
{canEdit && (
)}
-
- {displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
-
diff --git a/frontend/src/melodies/archetypes/ArchetypeList.jsx b/frontend/src/melodies/archetypes/ArchetypeList.jsx
index b3bb876..d538a95 100644
--- a/frontend/src/melodies/archetypes/ArchetypeList.jsx
+++ b/frontend/src/melodies/archetypes/ArchetypeList.jsx
@@ -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 (
+
e.target === e.currentTarget && !submitting && onClose()}
+ >
+
+
+
+
Upload Archetype
+
Upload .bin/.bsm and save as selectable archetype
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
setFile(e.target.files?.[0] || null)}
+ className="w-full px-3 py-2 rounded-md text-sm border"
+ disabled={submitting}
+ />
+ {file && (
+
{file.name}
+ )}
+
+
+
+ setName(e.target.value)}
+ className="w-full px-3 py-2 rounded-md text-sm border"
+ placeholder="e.g. Doxology Festive Upload"
+ disabled={submitting}
+ />
+
+
+
+ setPid(e.target.value)}
+ className="w-full px-3 py-2 rounded-md text-sm border"
+ placeholder="e.g. builtin_doxology_upload"
+ disabled={submitting}
+ />
+
+
+
+
+
+
+
+
+
+ );
+}
+
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.
-
+
+
+
+
{error && (
@@ -448,6 +610,13 @@ export default function ArchetypeList() {
primaryLang={primaryLang}
onClose={() => setAssignedModal(null)}
/>
+
+
setShowUploadModal(false)}
+ onUploaded={() => loadArchetypes()}
+ />
);
}