CODEX - MAJOR overhaul to the columns and minor changes to the melody detail and form
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -47,6 +47,12 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
|
|||||||
return Math.round(a * Math.pow(b / a, t));
|
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 }) {
|
function Field({ label, children }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -324,31 +330,6 @@ export default function MelodyDetail() {
|
|||||||
Melody Information
|
Melody Information
|
||||||
</h2>
|
</h2>
|
||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Type">
|
|
||||||
<span className="capitalize">{melody.type}</span>
|
|
||||||
</Field>
|
|
||||||
<Field label="Tone">
|
|
||||||
<span className="capitalize">{info.melodyTone}</span>
|
|
||||||
</Field>
|
|
||||||
<Field label="Steps">{info.steps}</Field>
|
|
||||||
<Field label="Total Archetype Notes">{info.totalNotes}</Field>
|
|
||||||
<Field label="Total Active Bells">{info.totalActiveBells ?? "-"}</Field>
|
|
||||||
<Field label="Min Speed">
|
|
||||||
{info.minSpeed ? (
|
|
||||||
<div>
|
|
||||||
<div>{minBpm} BPM</div>
|
|
||||||
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{info.minSpeed} ms</div>
|
|
||||||
</div>
|
|
||||||
) : "-"}
|
|
||||||
</Field>
|
|
||||||
<Field label="Max Speed">
|
|
||||||
{info.maxSpeed ? (
|
|
||||||
<div>
|
|
||||||
<div>{maxBpm} BPM</div>
|
|
||||||
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{info.maxSpeed} ms</div>
|
|
||||||
</div>
|
|
||||||
) : "-"}
|
|
||||||
</Field>
|
|
||||||
<Field label="Color">
|
<Field label="Color">
|
||||||
{info.color ? (
|
{info.color ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<span className="inline-flex items-center gap-2">
|
||||||
@@ -362,11 +343,25 @@ export default function MelodyDetail() {
|
|||||||
"-"
|
"-"
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="True Ring">
|
<Field label="Tone">
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={badgeStyle(info.isTrueRing)}>
|
<span className="capitalize">{info.melodyTone}</span>
|
||||||
{info.isTrueRing ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field label="Steps">{info.steps}</Field>
|
||||||
|
<Field label="Min Speed">
|
||||||
|
{info.minSpeed ? (
|
||||||
|
<span>
|
||||||
|
{minBpm} bpm <span className="text-xs" style={{ color: "var(--text-muted)" }}>· {info.minSpeed} ms</span>
|
||||||
|
</span>
|
||||||
|
) : "-"}
|
||||||
|
</Field>
|
||||||
|
<Field label="Max Speed">
|
||||||
|
{info.maxSpeed ? (
|
||||||
|
<span>
|
||||||
|
{maxBpm} bpm <span className="text-xs" style={{ color: "var(--text-muted)" }}>· {info.maxSpeed} ms</span>
|
||||||
|
</span>
|
||||||
|
) : "-"}
|
||||||
|
</Field>
|
||||||
|
<Field label="Unique Bells">{info.totalActiveBells ?? "-"}</Field>
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<Field label="Description">
|
<Field label="Description">
|
||||||
{getLocalizedValue(info.description, displayLang)}
|
{getLocalizedValue(info.description, displayLang)}
|
||||||
@@ -428,7 +423,7 @@ export default function MelodyDetail() {
|
|||||||
<Field label="Speed">
|
<Field label="Speed">
|
||||||
{settings.speed != null ? (
|
{settings.speed != null ? (
|
||||||
<span>
|
<span>
|
||||||
{settings.speed}%{speedBpm ? ` · ${speedBpm} BPM` : ""}{speedMs ? ` · ${speedMs} ms` : ""}
|
{settings.speed}%{speedBpm ? <span className="text-xs" style={{ color: "var(--text-muted)" }}>{` · ${speedBpm} bpm`}</span> : ""}{speedMs ? <span className="text-xs" style={{ color: "var(--text-muted)" }}>{` · ${speedMs} ms`}</span> : ""}
|
||||||
</span>
|
</span>
|
||||||
) : "-"}
|
) : "-"}
|
||||||
</Field>
|
</Field>
|
||||||
@@ -452,7 +447,11 @@ export default function MelodyDetail() {
|
|||||||
<dd>
|
<dd>
|
||||||
{settings.noteAssignments?.length > 0 ? (
|
{settings.noteAssignments?.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{settings.noteAssignments.map((assignedBell, noteIdx) => (
|
{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 (
|
||||||
<div
|
<div
|
||||||
key={noteIdx}
|
key={noteIdx}
|
||||||
className="flex flex-col items-center rounded-md border"
|
className="flex flex-col items-center rounded-md border"
|
||||||
@@ -460,18 +459,20 @@ export default function MelodyDetail() {
|
|||||||
minWidth: "36px",
|
minWidth: "36px",
|
||||||
padding: "4px 6px",
|
padding: "4px 6px",
|
||||||
backgroundColor: "var(--bg-card-hover)",
|
backgroundColor: "var(--bg-card-hover)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: `hsla(${noteHue}, 65%, 55%, 0.45)`,
|
||||||
|
boxShadow: `0 0 6px hsla(${noteHue}, 75%, 55%, 0.25) inset`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: `hsl(${noteHue}, 80%, 72%)` }}>
|
||||||
{String.fromCharCode(65 + noteIdx)}
|
{String.fromCharCode(65 + noteIdx)}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: assignedBell > 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}>
|
||||||
{assignedBell > 0 ? assignedBell : "—"}
|
{assignedBell > 0 ? assignedBell : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: "var(--text-muted)" }}>-</span>
|
<span style={{ color: "var(--text-muted)" }}>-</span>
|
||||||
@@ -566,6 +567,9 @@ export default function MelodyDetail() {
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{info.totalNotes ?? 0} active notes
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -725,3 +729,7 @@ export default function MelodyDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
@@ -110,6 +110,8 @@ 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);
|
||||||
|
|
||||||
// Metadata / Admin Notes
|
// Metadata / Admin Notes
|
||||||
const [adminNotes, setAdminNotes] = useState([]);
|
const [adminNotes, setAdminNotes] = useState([]);
|
||||||
@@ -192,6 +194,40 @@ export default function MelodyForm() {
|
|||||||
if (!str.trim()) return [];
|
if (!str.trim()) return [];
|
||||||
return str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
|
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 updateLocalizedField = (fieldKey, text) => {
|
||||||
const dict = parseLocalizedString(information[fieldKey]);
|
const dict = parseLocalizedString(information[fieldKey]);
|
||||||
@@ -529,7 +565,7 @@ export default function MelodyForm() {
|
|||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
|
||||||
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
{information.minSpeed > 0 && (
|
{information.minSpeed > 0 && (
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} BPM · {information.minSpeed} ms</p>
|
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -537,7 +573,7 @@ export default function MelodyForm() {
|
|||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
|
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
|
||||||
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
|
||||||
{information.maxSpeed > 0 && (
|
{information.maxSpeed > 0 && (
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} BPM · {information.maxSpeed} ms</p>
|
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -621,7 +657,7 @@ export default function MelodyForm() {
|
|||||||
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
|
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs mt-1" style={mutedStyle}>
|
<div className="text-xs mt-1" style={mutedStyle}>
|
||||||
{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"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -683,23 +719,48 @@ export default function MelodyForm() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<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 / .bin)</label>
|
||||||
{existingFiles.binary_url && (
|
{(() => {
|
||||||
<div className="text-xs mb-1" style={{ color: "var(--success)" }}>
|
const binaryUrl = existingFiles.binary_url || url || null;
|
||||||
{assignedBinaryName ? (
|
const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download");
|
||||||
<>
|
const binaryName = resolveFilename(binaryUrl, fallback);
|
||||||
<p><strong>{assignedBinaryName}</strong> has been assigned. Selecting a new file will replace it.</p>
|
return (
|
||||||
{assignedBinaryPid && (
|
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
|
||||||
<p className="mt-0.5" style={{ color: "var(--text-muted)" }}>
|
{binaryUrl ? (
|
||||||
filename: <span className="font-mono">{assignedBinaryPid}.bsm</span>
|
<a
|
||||||
|
href={binaryUrl}
|
||||||
|
onClick={(e) => downloadExistingFile(binaryUrl, binaryName, e)}
|
||||||
|
className="underline"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
{binaryName}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>No binary uploaded</span>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5">{information.totalNotes ?? 0} active notes</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<input
|
||||||
|
ref={binaryInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".bin,.bsm"
|
||||||
|
onChange={(e) => setBinaryFile(e.target.files[0] || null)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => binaryInputRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-full transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Choose Binary File
|
||||||
|
</button>
|
||||||
|
{binaryFile && (
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>
|
||||||
|
selected: {binaryFile.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p>Current file uploaded. Selecting a new file will replace it.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -748,13 +809,41 @@ export default function MelodyForm() {
|
|||||||
</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>
|
||||||
{existingFiles.preview_url && (
|
{existingFiles.preview_url ? (
|
||||||
<div className="mb-1">
|
<div className="mb-2 space-y-1">
|
||||||
<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current preview uploaded. Selecting a new file will replace it.</p>
|
<a
|
||||||
|
href={existingFiles.preview_url}
|
||||||
|
onClick={(e) => downloadExistingFile(existingFiles.preview_url, resolveFilename(existingFiles.preview_url, "preview.mp3"), e)}
|
||||||
|
className="underline text-xs"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
{resolveFilename(existingFiles.preview_url, "Click to Download")}
|
||||||
|
</a>
|
||||||
<audio controls src={existingFiles.preview_url} className="h-8" />
|
<audio controls src={existingFiles.preview_url} className="h-8" />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs mb-2" style={mutedStyle}>No preview uploaded</p>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={previewInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".mp3,.wav,.ogg"
|
||||||
|
onChange={(e) => setPreviewFile(e.target.files[0] || null)}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => previewInputRef.current?.click()}
|
||||||
|
className="px-3 py-1.5 text-xs rounded-full transition-colors"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Choose Preview File
|
||||||
|
</button>
|
||||||
|
{previewFile && (
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>
|
||||||
|
selected: {previewFile.name}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<input type="file" accept=".mp3,.wav,.ogg" onChange={(e) => setPreviewFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -910,3 +999,8 @@ export default function MelodyForm() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,7 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "description", label: "Description", defaultOn: false },
|
{ key: "description", label: "Description", defaultOn: false },
|
||||||
{ key: "type", label: "Type", defaultOn: true },
|
{ key: "type", label: "Type", defaultOn: true },
|
||||||
{ key: "tone", label: "Tone", defaultOn: true },
|
{ key: "tone", label: "Tone", defaultOn: true },
|
||||||
{ key: "totalNotes", label: "Total Notes", defaultOn: true },
|
{ key: "totalActiveBells", label: "Unique Bells", defaultOn: true },
|
||||||
{ key: "totalActiveBells", label: "Total Active Bells", defaultOn: true },
|
|
||||||
{ key: "minSpeed", label: "Min Speed", defaultOn: false },
|
{ key: "minSpeed", label: "Min Speed", defaultOn: false },
|
||||||
{ key: "maxSpeed", label: "Max Speed", defaultOn: false },
|
{ key: "maxSpeed", label: "Max Speed", defaultOn: false },
|
||||||
{ key: "tags", label: "Tags", defaultOn: false },
|
{ key: "tags", label: "Tags", defaultOn: false },
|
||||||
@@ -42,7 +41,7 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
||||||
{ key: "binaryFile", label: "Binary File", defaultOn: false },
|
{ key: "binaryFile", label: "Binary File", defaultOn: false },
|
||||||
{ key: "dateCreated", label: "Date Created", 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: "createdBy", label: "Created By", defaultOn: false },
|
||||||
{ key: "lastEditedBy", label: "Last Edited By", defaultOn: false },
|
{ key: "lastEditedBy", label: "Last Edited By", defaultOn: false },
|
||||||
{ key: "isTrueRing", label: "True Ring", defaultOn: true },
|
{ key: "isTrueRing", label: "True Ring", defaultOn: true },
|
||||||
@@ -102,6 +101,49 @@ function parseDateValue(isoValue) {
|
|||||||
return Number.isNaN(time) ? 0 : time;
|
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) {
|
function getBinaryUrl(row) {
|
||||||
const candidate = row?.url;
|
const candidate = row?.url;
|
||||||
if (!candidate || typeof candidate !== "string") return null;
|
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
|
// Close dropdowns on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e) => {
|
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) => {
|
const toggleCreator = (creator) => {
|
||||||
setCreatedByFilter((prev) =>
|
setCreatedByFilter((prev) =>
|
||||||
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
||||||
@@ -416,8 +485,6 @@ export default function MelodyList() {
|
|||||||
}
|
}
|
||||||
case "tone":
|
case "tone":
|
||||||
return <span className="capitalize">{info.melodyTone || "-"}</span>;
|
return <span className="capitalize">{info.melodyTone || "-"}</span>;
|
||||||
case "totalNotes":
|
|
||||||
return info.totalNotes ?? "-";
|
|
||||||
case "totalActiveBells":
|
case "totalActiveBells":
|
||||||
return info.totalActiveBells ?? "-";
|
return info.totalActiveBells ?? "-";
|
||||||
case "minSpeed":
|
case "minSpeed":
|
||||||
@@ -499,7 +566,7 @@ export default function MelodyList() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-28">
|
<div className="min-w-28">
|
||||||
<div className="text-xs mb-1" style={{ color: "var(--text-secondary)" }}>
|
<div className="text-xs mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
{formatDuration(ds.duration)}
|
{formatDurationVerbose(ds.duration)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="w-full h-2 rounded-full"
|
className="w-full h-2 rounded-full"
|
||||||
@@ -536,6 +603,11 @@ export default function MelodyList() {
|
|||||||
return ds.noteAssignments?.length > 0 ? (
|
return ds.noteAssignments?.length > 0 ? (
|
||||||
<div className="flex flex-nowrap gap-1 whitespace-nowrap">
|
<div className="flex flex-nowrap gap-1 whitespace-nowrap">
|
||||||
{ds.noteAssignments.map((assignedBell, noteIdx) => (
|
{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 (
|
||||||
<div
|
<div
|
||||||
key={noteIdx}
|
key={noteIdx}
|
||||||
className="flex flex-col items-center rounded-md border"
|
className="flex flex-col items-center rounded-md border"
|
||||||
@@ -543,17 +615,20 @@ export default function MelodyList() {
|
|||||||
minWidth: "26px",
|
minWidth: "26px",
|
||||||
padding: "3px 3px",
|
padding: "3px 3px",
|
||||||
backgroundColor: "var(--bg-card-hover)",
|
backgroundColor: "var(--bg-card-hover)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: `hsla(${noteHue}, 65%, 55%, 0.45)`,
|
||||||
|
boxShadow: `0 0 6px hsla(${noteHue}, 75%, 55%, 0.25) inset`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: `hsl(${noteHue}, 80%, 72%)` }}>
|
||||||
{NOTE_LABELS[noteIdx]}
|
{NOTE_LABELS[noteIdx]}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: assignedBell > 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}>
|
||||||
{assignedBell > 0 ? assignedBell : "—"}
|
{assignedBell > 0 ? assignedBell : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -562,8 +637,17 @@ export default function MelodyList() {
|
|||||||
case "binaryFile": {
|
case "binaryFile": {
|
||||||
const binaryUrl = getBinaryUrl(row);
|
const binaryUrl = getBinaryUrl(row);
|
||||||
const filename = getBinaryFilename(row);
|
const filename = getBinaryFilename(row);
|
||||||
if (!binaryUrl) return <span style={{ color: "var(--text-muted)" }}>-</span>;
|
const totalNotes = info.totalNotes ?? 0;
|
||||||
|
if (!binaryUrl) {
|
||||||
return (
|
return (
|
||||||
|
<span className="inline-flex flex-col">
|
||||||
|
<span style={{ color: "var(--text-muted)" }}>-</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{totalNotes} active notes</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-col">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => downloadBinary(e, row)}
|
onClick={(e) => downloadBinary(e, row)}
|
||||||
@@ -571,14 +655,29 @@ export default function MelodyList() {
|
|||||||
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
||||||
title={binaryUrl}
|
title={binaryUrl}
|
||||||
>
|
>
|
||||||
{filename}
|
{filename || "Click to Download"}
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{totalNotes} active notes</span>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "dateCreated":
|
case "dateCreated":
|
||||||
return metadata.dateCreated ? new Date(metadata.dateCreated).toLocaleString() : "-";
|
if (!metadata.dateCreated) return "-";
|
||||||
|
{
|
||||||
|
const parts = formatDateTwoLine(metadata.dateCreated);
|
||||||
|
return (
|
||||||
|
<span className="inline-flex flex-col leading-tight">
|
||||||
|
<span>{parts.date}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{parts.time}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
case "dateEdited":
|
case "dateEdited":
|
||||||
return metadata.dateEdited ? new Date(metadata.dateEdited).toLocaleString() : "-";
|
return metadata.dateEdited ? (
|
||||||
|
<span title={new Date(metadata.dateEdited).toLocaleString()}>
|
||||||
|
{formatRelativeTime(metadata.dateEdited)}
|
||||||
|
</span>
|
||||||
|
) : "-";
|
||||||
case "createdBy":
|
case "createdBy":
|
||||||
return metadata.createdBy || "-";
|
return metadata.createdBy || "-";
|
||||||
case "lastEditedBy":
|
case "lastEditedBy":
|
||||||
@@ -604,16 +703,19 @@ export default function MelodyList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build visible column list (description is rendered inside name, not as its own column)
|
// Build visible column list in user-defined order (description is rendered inside name)
|
||||||
const activeColumns = ALL_COLUMNS.filter((c) => c.key !== "description" && isVisible(c.key));
|
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 languages = melodySettings?.available_languages || ["en"];
|
||||||
|
|
||||||
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>
|
<div className="w-full min-w-0">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<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 && (
|
||||||
<button
|
<button
|
||||||
@@ -626,7 +728,7 @@ export default function MelodyList() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 space-y-3">
|
<div className="mb-4 space-y-3 w-full">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
placeholder="Search by name, description, or tags..."
|
placeholder="Search by name, description, or tags..."
|
||||||
@@ -754,7 +856,10 @@ export default function MelodyList() {
|
|||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ALL_COLUMNS.map((col) => (
|
{ALL_COLUMNS.map((col) => {
|
||||||
|
const orderIdx = visibleColumns.indexOf(col.key);
|
||||||
|
const canMove = orderIdx >= 0;
|
||||||
|
return (
|
||||||
<label
|
<label
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
|
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
|
||||||
@@ -767,9 +872,32 @@ export default function MelodyList() {
|
|||||||
disabled={col.alwaysOn}
|
disabled={col.alwaysOn}
|
||||||
className="h-3.5 w-3.5 rounded cursor-pointer"
|
className="h-3.5 w-3.5 rounded cursor-pointer"
|
||||||
/>
|
/>
|
||||||
{col.label}
|
<span className="flex-1">{col.label}</span>
|
||||||
|
{canMove && (
|
||||||
|
<span className="inline-flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveColumn(col.key, "up")}
|
||||||
|
className="text-[10px] px-1 rounded border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
||||||
|
title="Move left"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => moveColumn(col.key, "down")}
|
||||||
|
className="text-[10px] px-1 rounded border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
|
||||||
|
title="Move right"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -808,14 +936,14 @@ export default function MelodyList() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border max-w-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto max-w-full">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm min-w-max">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
{activeColumns.map((col) => (
|
{activeColumns.map((col) => (
|
||||||
@@ -942,3 +1070,4 @@ export default function MelodyList() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user