diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 1630046..475d895 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; @@ -47,6 +47,12 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) { return Math.round(a * Math.pow(b / a, t)); } +function hueForDepth(index, count) { + const safeCount = Math.max(1, count); + const t = Math.max(0, Math.min(1, index / safeCount)); + return 190 + (15 - 190) * t; +} + function Field({ label, children }) { return (
@@ -324,31 +330,6 @@ export default function MelodyDetail() { Melody Information
- - {melody.type} - - - {info.melodyTone} - - {info.steps} - {info.totalNotes} - {info.totalActiveBells ?? "-"} - - {info.minSpeed ? ( -
-
{minBpm} BPM
-
{info.minSpeed} ms
-
- ) : "-"} -
- - {info.maxSpeed ? ( -
-
{maxBpm} BPM
-
{info.maxSpeed} ms
-
- ) : "-"} -
{info.color ? ( @@ -362,11 +343,25 @@ export default function MelodyDetail() { "-" )} - - - {info.isTrueRing ? "Yes" : "No"} - + + {info.melodyTone} + {info.steps} + + {info.minSpeed ? ( + + {minBpm} bpm · {info.minSpeed} ms + + ) : "-"} + + + {info.maxSpeed ? ( + + {maxBpm} bpm · {info.maxSpeed} ms + + ) : "-"} + + {info.totalActiveBells ?? "-"}
{getLocalizedValue(info.description, displayLang)} @@ -428,7 +423,7 @@ export default function MelodyDetail() { {settings.speed != null ? ( - {settings.speed}%{speedBpm ? ` · ${speedBpm} BPM` : ""}{speedMs ? ` · ${speedMs} ms` : ""} + {settings.speed}%{speedBpm ? {` · ${speedBpm} bpm`} : ""}{speedMs ? {` · ${speedMs} ms`} : ""} ) : "-"} @@ -452,26 +447,32 @@ export default function MelodyDetail() {
{settings.noteAssignments?.length > 0 ? (
- {settings.noteAssignments.map((assignedBell, noteIdx) => ( -
- - {String.fromCharCode(65 + noteIdx)} - -
- - {assignedBell > 0 ? assignedBell : "—"} - -
- ))} + {settings.noteAssignments.map((assignedBell, noteIdx) => { + const noteHue = hueForDepth(noteIdx, settings.noteAssignments.length - 1); + const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1)); + const bellHue = hueForDepth(bellDepthIdx, 15); + return ( +
+ + {String.fromCharCode(65 + noteIdx)} + +
+ 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}> + {assignedBell > 0 ? assignedBell : "—"} + +
+ ); + })}
) : ( - @@ -566,6 +567,9 @@ export default function MelodyDetail() { )} + + {info.totalNotes ?? 0} active notes + ); })()} @@ -725,3 +729,7 @@ export default function MelodyDetail() {
); } + + + + diff --git a/frontend/src/melodies/MelodyForm.jsx b/frontend/src/melodies/MelodyForm.jsx index 1817cf2..f21e5b4 100644 --- a/frontend/src/melodies/MelodyForm.jsx +++ b/frontend/src/melodies/MelodyForm.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useNavigate, useParams } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; @@ -110,6 +110,8 @@ 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 const [adminNotes, setAdminNotes] = useState([]); @@ -192,6 +194,40 @@ export default function MelodyForm() { if (!str.trim()) return []; return str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n)); }; + const resolveFilename = (fileUrl, fallbackName) => { + if (!fileUrl) return fallbackName; + try { + const path = decodeURIComponent(new URL(fileUrl, window.location.origin).pathname); + const parts = path.split("/"); + return parts[parts.length - 1] || fallbackName; + } catch { + return fallbackName; + } + }; + + const downloadExistingFile = async (fileUrl, fallbackName, e) => { + e?.preventDefault?.(); + if (!fileUrl) return; + try { + const token = localStorage.getItem("access_token"); + let res = await fetch(fileUrl, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok && fileUrl.startsWith("http")) { + res = await fetch(fileUrl); + } + if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); + const blob = await res.blob(); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = fallbackName || "download.file"; + a.click(); + URL.revokeObjectURL(objectUrl); + } catch (err) { + setError(err.message); + } + }; const updateLocalizedField = (fieldKey, text) => { const dict = parseLocalizedString(information[fieldKey]); @@ -529,7 +565,7 @@ export default function MelodyForm() { updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} /> {information.minSpeed > 0 && ( -

{minBpm} BPM · {information.minSpeed} ms

+

{minBpm} bpm · {information.minSpeed} ms

)}
@@ -537,7 +573,7 @@ export default function MelodyForm() { updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} /> {information.maxSpeed > 0 && ( -

{maxBpm} BPM · {information.maxSpeed} ms

+

{maxBpm} bpm · {information.maxSpeed} ms

)}
@@ -621,7 +657,7 @@ export default function MelodyForm() { {settings.speed}%
- {speedBpm && speedMs ? `${speedBpm} BPM · ${speedMs} ms` : "Set MIN/MAX speed to compute BPM"} + {speedBpm && speedMs ? `${speedBpm} bpm · ${speedMs} ms` : "Set MIN/MAX speed to compute bpm"}
@@ -683,23 +719,48 @@ export default function MelodyForm() {
- {existingFiles.binary_url && ( -
- {assignedBinaryName ? ( - <> -

{assignedBinaryName} has been assigned. Selecting a new file will replace it.

- {assignedBinaryPid && ( -

- filename: {assignedBinaryPid}.bsm -

- )} - - ) : ( -

Current file uploaded. Selecting a new file will replace it.

- )} -
+ {(() => { + const binaryUrl = existingFiles.binary_url || url || null; + const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download"); + const binaryName = resolveFilename(binaryUrl, fallback); + return ( +
+ {binaryUrl ? ( + downloadExistingFile(binaryUrl, binaryName, e)} + className="underline" + style={{ color: "var(--accent)" }} + > + {binaryName} + + ) : ( + No binary uploaded + )} +
{information.totalNotes ?? 0} active notes
+
+ ); + })()} + setBinaryFile(e.target.files[0] || null)} + className="hidden" + /> + + {binaryFile && ( +

+ selected: {binaryFile.name} +

)} - setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
+ {previewFile && ( +

+ selected: {previewFile.name} +

)} - setPreviewFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
@@ -910,3 +999,8 @@ export default function MelodyForm() {
); } + + + + + diff --git a/frontend/src/melodies/MelodyList.jsx b/frontend/src/melodies/MelodyList.jsx index 66e6463..333e9f0 100644 --- a/frontend/src/melodies/MelodyList.jsx +++ b/frontend/src/melodies/MelodyList.jsx @@ -29,8 +29,7 @@ const ALL_COLUMNS = [ { key: "description", label: "Description", defaultOn: false }, { key: "type", label: "Type", defaultOn: true }, { key: "tone", label: "Tone", defaultOn: true }, - { key: "totalNotes", label: "Total Notes", defaultOn: true }, - { key: "totalActiveBells", label: "Total Active Bells", defaultOn: true }, + { key: "totalActiveBells", label: "Unique Bells", defaultOn: true }, { key: "minSpeed", label: "Min Speed", defaultOn: false }, { key: "maxSpeed", label: "Max Speed", defaultOn: false }, { key: "tags", label: "Tags", defaultOn: false }, @@ -42,7 +41,7 @@ const ALL_COLUMNS = [ { key: "noteAssignments", label: "Note Assignments", defaultOn: false }, { key: "binaryFile", label: "Binary File", defaultOn: false }, { key: "dateCreated", label: "Date Created", defaultOn: false }, - { key: "dateEdited", label: "Date Edited", defaultOn: false }, + { key: "dateEdited", label: "Last Edited", defaultOn: false }, { key: "createdBy", label: "Created By", defaultOn: false }, { key: "lastEditedBy", label: "Last Edited By", defaultOn: false }, { key: "isTrueRing", label: "True Ring", defaultOn: true }, @@ -102,6 +101,49 @@ function parseDateValue(isoValue) { return Number.isNaN(time) ? 0 : time; } +function formatDurationVerbose(seconds) { + const total = Number(seconds || 0); + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${mins} min ${secs} sec`; +} + +function formatDateTwoLine(isoValue) { + const d = new Date(isoValue); + if (Number.isNaN(d.getTime())) return { date: "-", time: "-" }; + const dd = String(d.getDate()).padStart(2, "0"); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const yyyy = d.getFullYear(); + const hh = String(d.getHours()).padStart(2, "0"); + const min = String(d.getMinutes()).padStart(2, "0"); + return { date: `${dd}/${mm}/${yyyy}`, time: `${hh}:${min}` }; +} + +function formatRelativeTime(isoValue) { + if (!isoValue) return "-"; + const ts = new Date(isoValue).getTime(); + if (Number.isNaN(ts)) return "-"; + const now = Date.now(); + const diffSec = Math.max(1, Math.floor((now - ts) / 1000)); + if (diffSec < 60) return `${diffSec} sec ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin} min ago`; + const diffHours = Math.floor(diffMin / 60); + if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths} month${diffMonths === 1 ? "" : "s"} ago`; + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`; +} + +function hueForDepth(index, count) { + const safeCount = Math.max(1, count); + const t = Math.max(0, Math.min(1, index / safeCount)); + return 190 + (15 - 190) * t; // high notes blue-ish -> deep notes warm red +} + function getBinaryUrl(row) { const candidate = row?.url; if (!candidate || typeof candidate !== "string") return null; @@ -160,6 +202,20 @@ export default function MelodyList() { }); }, []); + useEffect(() => { + setVisibleColumns((prev) => { + const known = new Set(ALL_COLUMNS.map((c) => c.key)); + let next = prev.filter((k) => known.has(k)); + for (const col of ALL_COLUMNS) { + if (col.alwaysOn && !next.includes(col.key)) next.push(col.key); + } + if (JSON.stringify(next) !== JSON.stringify(prev)) { + localStorage.setItem("melodyListColumns", JSON.stringify(next)); + } + return next; + }); + }, []); + // Close dropdowns on outside click useEffect(() => { const handleClick = (e) => { @@ -279,6 +335,19 @@ export default function MelodyList() { }); }; + const moveColumn = (key, direction) => { + setVisibleColumns((prev) => { + const idx = prev.indexOf(key); + if (idx < 0) return prev; + const swapIdx = direction === "up" ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= prev.length) return prev; + const next = [...prev]; + [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; + localStorage.setItem("melodyListColumns", JSON.stringify(next)); + return next; + }); + }; + const toggleCreator = (creator) => { setCreatedByFilter((prev) => prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator] @@ -416,8 +485,6 @@ export default function MelodyList() { } case "tone": return {info.melodyTone || "-"}; - case "totalNotes": - return info.totalNotes ?? "-"; case "totalActiveBells": return info.totalActiveBells ?? "-"; case "minSpeed": @@ -499,7 +566,7 @@ export default function MelodyList() { return (
- {formatDuration(ds.duration)} + {formatDurationVerbose(ds.duration)}
0 ? (
{ds.noteAssignments.map((assignedBell, noteIdx) => ( + (() => { + const noteHue = hueForDepth(noteIdx, ds.noteAssignments.length - 1); + const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1)); + const bellHue = hueForDepth(bellDepthIdx, 15); + return (
- + {NOTE_LABELS[noteIdx]}
- + 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}> {assignedBell > 0 ? assignedBell : "—"}
+ ); + })() ))}
) : ( @@ -562,23 +637,47 @@ export default function MelodyList() { case "binaryFile": { const binaryUrl = getBinaryUrl(row); const filename = getBinaryFilename(row); - if (!binaryUrl) return -; + const totalNotes = info.totalNotes ?? 0; + if (!binaryUrl) { + return ( + + - + {totalNotes} active notes + + ); + } return ( - + + + {totalNotes} active notes + ); } case "dateCreated": - return metadata.dateCreated ? new Date(metadata.dateCreated).toLocaleString() : "-"; + if (!metadata.dateCreated) return "-"; + { + const parts = formatDateTwoLine(metadata.dateCreated); + return ( + + {parts.date} + {parts.time} + + ); + } case "dateEdited": - return metadata.dateEdited ? new Date(metadata.dateEdited).toLocaleString() : "-"; + return metadata.dateEdited ? ( + + {formatRelativeTime(metadata.dateEdited)} + + ) : "-"; case "createdBy": return metadata.createdBy || "-"; case "lastEditedBy": @@ -604,16 +703,19 @@ export default function MelodyList() { } }; - // Build visible column list (description is rendered inside name, not as its own column) - const activeColumns = ALL_COLUMNS.filter((c) => c.key !== "description" && isVisible(c.key)); + // Build visible column list in user-defined order (description is rendered inside name) + const activeColumns = visibleColumns + .filter((key) => key !== "description") + .map((key) => ALL_COLUMNS.find((c) => c.key === key)) + .filter(Boolean); const languages = melodySettings?.available_languages || ["en"]; const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border"; return ( -
-
+
+

Melodies

{canEdit && ( + + + )} - ))} + ); + })}
)}
@@ -808,14 +936,14 @@ export default function MelodyList() {
) : (
-
- +
+
{activeColumns.map((col) => ( @@ -942,3 +1070,4 @@ export default function MelodyList() { ); } +