CODEX - Further changes performed
This commit is contained in:
@@ -355,88 +355,91 @@ export default function MelodyComposer() {
|
|||||||
className="rounded-lg border p-4"
|
className="rounded-lg border p-4"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-end">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div className="xl:col-span-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
{!isPlaying ? (
|
||||||
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={loopEnabled}
|
|
||||||
onChange={(e) => setLoopEnabled(e.target.checked)}
|
|
||||||
/>
|
|
||||||
Loop
|
|
||||||
</label>
|
|
||||||
{!isPlaying ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handlePlay}
|
|
||||||
className="px-4 py-2 rounded-md text-sm"
|
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
||||||
>
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={stopPlayback}
|
|
||||||
className="px-4 py-2 rounded-md text-sm"
|
|
||||||
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openDeployModal}
|
onClick={handlePlay}
|
||||||
className="px-4 py-2 rounded-md text-sm"
|
className="px-4 py-2 rounded-md text-sm"
|
||||||
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
>
|
>
|
||||||
Deploy as Archetype
|
Play
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopPlayback}
|
||||||
|
className="px-4 py-2 rounded-md text-sm"
|
||||||
|
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={loopEnabled}
|
||||||
|
onChange={(e) => setLoopEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Loop
|
||||||
|
</label>
|
||||||
|
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-5">
|
||||||
|
<div className="w-full sm:w-72">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Step Delay
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
||||||
|
{stepDelayMs} ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="40"
|
||||||
|
max="2000"
|
||||||
|
step="10"
|
||||||
|
value={stepDelayMs}
|
||||||
|
onChange={(e) => setStepDelayMs(Number(e.target.value))}
|
||||||
|
className="w-full mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full sm:w-72">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Note Duration
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
||||||
|
{noteDurationMs} ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="500"
|
||||||
|
step="10"
|
||||||
|
value={noteDurationMs}
|
||||||
|
onChange={(e) => setNoteDurationMs(Number(e.target.value))}
|
||||||
|
className="w-full mt-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="xl:col-span-5">
|
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
<div className="flex items-center justify-between">
|
<div className="ml-1">
|
||||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
<button
|
||||||
Step Delay
|
type="button"
|
||||||
</label>
|
onClick={openDeployModal}
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
className="px-4 py-2 rounded-md text-sm whitespace-nowrap"
|
||||||
{stepDelayMs} ms
|
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
|
||||||
</span>
|
>
|
||||||
</div>
|
Deploy Archetype
|
||||||
<input
|
</button>
|
||||||
type="range"
|
|
||||||
min="40"
|
|
||||||
max="2000"
|
|
||||||
step="10"
|
|
||||||
value={stepDelayMs}
|
|
||||||
onChange={(e) => setStepDelayMs(Number(e.target.value))}
|
|
||||||
className="w-full mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="xl:col-span-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Note Duration
|
|
||||||
</label>
|
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
|
||||||
{noteDurationMs} ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="20"
|
|
||||||
max="500"
|
|
||||||
step="10"
|
|
||||||
value={noteDurationMs}
|
|
||||||
onChange={(e) => setNoteDurationMs(Number(e.target.value))}
|
|
||||||
className="w-full mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentStep >= 0 && (
|
{currentStep >= 0 && (
|
||||||
@@ -522,13 +525,13 @@ export default function MelodyComposer() {
|
|||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{
|
style={{
|
||||||
width: "64%",
|
width: "54%",
|
||||||
height: "64%",
|
height: "54%",
|
||||||
borderRadius: "9999px",
|
borderRadius: "9999px",
|
||||||
backgroundColor: "var(--btn-primary)",
|
backgroundColor: "var(--btn-primary)",
|
||||||
opacity: enabled ? 1 : 0,
|
opacity: enabled ? 1 : 0,
|
||||||
transform: enabled ? "scale(1)" : "scale(0.4)",
|
transform: enabled ? "scale(1)" : "scale(0.4)",
|
||||||
boxShadow: enabled ? "0 0 12px 4px rgba(116, 184, 22, 0.55)" : "none",
|
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none",
|
||||||
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
|
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -588,7 +591,7 @@ export default function MelodyComposer() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
Deploy as Archetype
|
Deploy Archetype
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
Create a new archetype from this composer pattern.
|
Create a new archetype from this composer pattern.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { 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";
|
||||||
@@ -13,16 +13,18 @@ import {
|
|||||||
|
|
||||||
const MELODY_TYPES = ["", "orthodox", "catholic", "all"];
|
const MELODY_TYPES = ["", "orthodox", "catholic", "all"];
|
||||||
const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"];
|
const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"];
|
||||||
|
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||||
|
|
||||||
// All available columns with their defaults
|
// All available columns with their defaults
|
||||||
const ALL_COLUMNS = [
|
const ALL_COLUMNS = [
|
||||||
{ key: "color", label: "Color", defaultOn: true },
|
{ key: "color", label: "Color", defaultOn: true },
|
||||||
{ key: "status", label: "Status", defaultOn: true },
|
|
||||||
{ key: "name", label: "Name", defaultOn: true, alwaysOn: true },
|
{ key: "name", label: "Name", defaultOn: true, alwaysOn: true },
|
||||||
|
{ key: "status", label: "Status", defaultOn: true },
|
||||||
{ 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: "totalNotes", label: "Total Notes", 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 },
|
||||||
@@ -32,6 +34,11 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
||||||
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
|
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
|
||||||
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
{ 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: "createdBy", label: "Created 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 },
|
||||||
{ key: "docId", label: "Document ID", defaultOn: false },
|
{ key: "docId", label: "Document ID", defaultOn: false },
|
||||||
{ key: "pid", label: "PID", defaultOn: false },
|
{ key: "pid", label: "PID", defaultOn: false },
|
||||||
@@ -49,6 +56,43 @@ function getDefaultVisibleColumns() {
|
|||||||
return ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key);
|
return ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function speedBarColor(speedPercent) {
|
||||||
|
const v = Math.max(0, Math.min(100, Number(speedPercent || 0)));
|
||||||
|
const hue = (v / 100) * 120;
|
||||||
|
return `hsl(${hue}, 85%, 46%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateValue(isoValue) {
|
||||||
|
if (!isoValue) return 0;
|
||||||
|
const time = new Date(isoValue).getTime();
|
||||||
|
return Number.isNaN(time) ? 0 : time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBinaryUrl(row) {
|
||||||
|
const candidate = row?.url;
|
||||||
|
if (!candidate || typeof candidate !== "string") return null;
|
||||||
|
if (candidate.startsWith("http") || candidate.startsWith("/api")) return candidate;
|
||||||
|
if (candidate.startsWith("/")) return `/api${candidate}`;
|
||||||
|
return `/api/${candidate}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBinaryFilename(row) {
|
||||||
|
const url = getBinaryUrl(row);
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.origin);
|
||||||
|
const path = decodeURIComponent(parsed.pathname || "");
|
||||||
|
const parts = path.split("/");
|
||||||
|
const name = parts[parts.length - 1];
|
||||||
|
if (name && name.toLowerCase().endsWith(".bsm")) return name;
|
||||||
|
} catch {
|
||||||
|
// fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
return row?.pid ? `${row.pid}.bsm` : "melody.bsm";
|
||||||
|
}
|
||||||
|
|
||||||
export default function MelodyList() {
|
export default function MelodyList() {
|
||||||
const [melodies, setMelodies] = useState([]);
|
const [melodies, setMelodies] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -58,6 +102,9 @@ export default function MelodyList() {
|
|||||||
const [typeFilter, setTypeFilter] = useState("");
|
const [typeFilter, setTypeFilter] = useState("");
|
||||||
const [toneFilter, setToneFilter] = useState("");
|
const [toneFilter, setToneFilter] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("");
|
const [statusFilter, setStatusFilter] = useState("");
|
||||||
|
const [createdByFilter, setCreatedByFilter] = useState([]);
|
||||||
|
const [sortBy, setSortBy] = useState("dateCreated");
|
||||||
|
const [sortDir, setSortDir] = useState("desc");
|
||||||
const [displayLang, setDisplayLang] = useState("en");
|
const [displayLang, setDisplayLang] = useState("en");
|
||||||
const [melodySettings, setMelodySettings] = useState(null);
|
const [melodySettings, setMelodySettings] = useState(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
@@ -65,7 +112,9 @@ export default function MelodyList() {
|
|||||||
const [actionLoading, setActionLoading] = useState(null);
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
||||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||||
|
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
||||||
const columnPickerRef = useRef(null);
|
const columnPickerRef = useRef(null);
|
||||||
|
const creatorPickerRef = useRef(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
const canEdit = hasPermission("melodies", "edit");
|
const canEdit = hasPermission("melodies", "edit");
|
||||||
@@ -77,18 +126,21 @@ export default function MelodyList() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Close column picker on outside click
|
// Close dropdowns on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e) => {
|
const handleClick = (e) => {
|
||||||
if (columnPickerRef.current && !columnPickerRef.current.contains(e.target)) {
|
if (columnPickerRef.current && !columnPickerRef.current.contains(e.target)) {
|
||||||
setShowColumnPicker(false);
|
setShowColumnPicker(false);
|
||||||
}
|
}
|
||||||
|
if (creatorPickerRef.current && !creatorPickerRef.current.contains(e.target)) {
|
||||||
|
setShowCreatorPicker(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if (showColumnPicker) {
|
if (showColumnPicker || showCreatorPicker) {
|
||||||
document.addEventListener("mousedown", handleClick);
|
document.addEventListener("mousedown", handleClick);
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
return () => document.removeEventListener("mousedown", handleClick);
|
||||||
}
|
}
|
||||||
}, [showColumnPicker]);
|
}, [showColumnPicker, showCreatorPicker]);
|
||||||
|
|
||||||
const fetchMelodies = async () => {
|
const fetchMelodies = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -101,8 +153,8 @@ export default function MelodyList() {
|
|||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const data = await api.get(`/melodies${qs ? `?${qs}` : ""}`);
|
const data = await api.get(`/melodies${qs ? `?${qs}` : ""}`);
|
||||||
setMelodies(data.melodies);
|
setMelodies(data.melodies || []);
|
||||||
setTotal(data.total);
|
setTotal(data.total || 0);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,6 +205,34 @@ export default function MelodyList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadBinary = async (e, row) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const binaryUrl = getBinaryUrl(row);
|
||||||
|
if (!binaryUrl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
let res = await fetch(binaryUrl, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok && binaryUrl.startsWith("http")) {
|
||||||
|
res = await fetch(binaryUrl);
|
||||||
|
}
|
||||||
|
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 = getBinaryFilename(row) || "melody.bsm";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleColumn = (key) => {
|
const toggleColumn = (key) => {
|
||||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||||
if (col?.alwaysOn) return;
|
if (col?.alwaysOn) return;
|
||||||
@@ -165,22 +245,75 @@ export default function MelodyList() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleCreator = (creator) => {
|
||||||
|
setCreatedByFilter((prev) =>
|
||||||
|
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const isVisible = (key) => visibleColumns.includes(key);
|
const isVisible = (key) => visibleColumns.includes(key);
|
||||||
|
|
||||||
const getDisplayName = (nameVal) =>
|
const getDisplayName = (nameVal) => getLocalizedValue(nameVal, displayLang, "Untitled");
|
||||||
getLocalizedValue(nameVal, displayLang, "Untitled");
|
|
||||||
|
const allCreators = useMemo(() => {
|
||||||
|
const creators = new Set();
|
||||||
|
for (const row of melodies) {
|
||||||
|
const creator = row?.metadata?.createdBy;
|
||||||
|
if (creator) creators.add(creator);
|
||||||
|
}
|
||||||
|
return Array.from(creators).sort((a, b) => a.localeCompare(b));
|
||||||
|
}, [melodies]);
|
||||||
|
|
||||||
|
const getSortValue = (row, key) => {
|
||||||
|
const info = row?.information || {};
|
||||||
|
const metadata = row?.metadata || {};
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "name":
|
||||||
|
return getDisplayName(info.name).toLowerCase();
|
||||||
|
case "totalBells":
|
||||||
|
return Number(info.totalActiveBells || 0);
|
||||||
|
case "dateEdited":
|
||||||
|
return parseDateValue(metadata.dateEdited);
|
||||||
|
case "dateCreated":
|
||||||
|
default:
|
||||||
|
return parseDateValue(metadata.dateCreated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayRows = useMemo(() => {
|
||||||
|
let rows = melodies;
|
||||||
|
|
||||||
|
if (createdByFilter.length > 0) {
|
||||||
|
rows = rows.filter((row) => createdByFilter.includes(row?.metadata?.createdBy || ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...rows].sort((a, b) => {
|
||||||
|
const av = getSortValue(a, sortBy);
|
||||||
|
const bv = getSortValue(b, sortBy);
|
||||||
|
|
||||||
|
if (typeof av === "string" && typeof bv === "string") {
|
||||||
|
return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortDir === "asc" ? av - bv : bv - av;
|
||||||
|
});
|
||||||
|
}, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const renderCellValue = (key, row) => {
|
const renderCellValue = (key, row) => {
|
||||||
const info = row.information || {};
|
const info = row.information || {};
|
||||||
const ds = row.default_settings || {};
|
const ds = row.default_settings || {};
|
||||||
|
const metadata = row.metadata || {};
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "status":
|
case "status":
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="px-2 py-0.5 text-xs font-semibold rounded-full"
|
className="px-2 py-0.5 text-xs font-semibold rounded-full"
|
||||||
style={row.status === "published"
|
style={
|
||||||
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
|
row.status === "published"
|
||||||
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
|
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
|
||||||
|
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{row.status === "published" ? "Live" : "Draft"}
|
{row.status === "published" ? "Live" : "Draft"}
|
||||||
@@ -196,25 +329,33 @@ export default function MelodyList() {
|
|||||||
) : (
|
) : (
|
||||||
<span className="inline-block w-3 h-8 rounded-sm" style={{ backgroundColor: "var(--border-primary)" }} />
|
<span className="inline-block w-3 h-8 rounded-sm" style={{ backgroundColor: "var(--border-primary)" }} />
|
||||||
);
|
);
|
||||||
case "name":
|
case "name": {
|
||||||
|
const description = getLocalizedValue(info.description, displayLang) || "-";
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
|
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
|
||||||
{getDisplayName(info.name)}
|
{getDisplayName(info.name)}
|
||||||
</span>
|
</span>
|
||||||
{isVisible("description") && (
|
{isVisible("description") && (
|
||||||
<p className="text-xs mt-0.5 truncate max-w-xs" style={{ color: "var(--text-muted)" }}>
|
<p
|
||||||
{getLocalizedValue(info.description, displayLang) || "-"}
|
className="text-xs mt-0.5 truncate max-w-xs"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
title={description}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "type":
|
case "type":
|
||||||
return <span className="capitalize">{row.type}</span>;
|
return <span className="capitalize">{row.type}</span>;
|
||||||
case "tone":
|
case "tone":
|
||||||
return <span className="capitalize">{info.melodyTone || "-"}</span>;
|
return <span className="capitalize">{info.melodyTone || "-"}</span>;
|
||||||
case "totalNotes":
|
case "totalNotes":
|
||||||
return info.totalNotes ?? "-";
|
return info.totalNotes ?? "-";
|
||||||
|
case "totalActiveBells":
|
||||||
|
return info.totalActiveBells ?? "-";
|
||||||
case "minSpeed":
|
case "minSpeed":
|
||||||
return info.minSpeed ?? "-";
|
return info.minSpeed ?? "-";
|
||||||
case "maxSpeed":
|
case "maxSpeed":
|
||||||
@@ -236,7 +377,26 @@ export default function MelodyList() {
|
|||||||
"-"
|
"-"
|
||||||
);
|
);
|
||||||
case "speed":
|
case "speed":
|
||||||
return ds.speed != null ? `${ds.speed}%` : "-";
|
if (ds.speed == null) return "-";
|
||||||
|
return (
|
||||||
|
<div className="min-w-28">
|
||||||
|
<div className="text-xs mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{ds.speed}%
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-full h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(0, Math.min(100, ds.speed))}%`,
|
||||||
|
backgroundColor: speedBarColor(ds.speed),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case "duration":
|
case "duration":
|
||||||
return ds.duration != null ? formatDuration(ds.duration) : "-";
|
return ds.duration != null ? formatDuration(ds.duration) : "-";
|
||||||
case "totalRunDuration":
|
case "totalRunDuration":
|
||||||
@@ -256,9 +416,56 @@ export default function MelodyList() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "noteAssignments":
|
case "noteAssignments":
|
||||||
return ds.noteAssignments?.length > 0
|
return ds.noteAssignments?.length > 0 ? (
|
||||||
? ds.noteAssignments.join(", ")
|
<div className="flex flex-wrap gap-1.5">
|
||||||
: "-";
|
{ds.noteAssignments.map((assignedBell, noteIdx) => (
|
||||||
|
<div
|
||||||
|
key={noteIdx}
|
||||||
|
className="flex flex-col items-center rounded-md border"
|
||||||
|
style={{
|
||||||
|
minWidth: "30px",
|
||||||
|
padding: "3px 5px",
|
||||||
|
backgroundColor: "var(--bg-card-hover)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{NOTE_LABELS[noteIdx]}
|
||||||
|
</span>
|
||||||
|
<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)" }}>
|
||||||
|
{assignedBell > 0 ? assignedBell : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
);
|
||||||
|
case "binaryFile": {
|
||||||
|
const binaryUrl = getBinaryUrl(row);
|
||||||
|
const filename = getBinaryFilename(row);
|
||||||
|
if (!binaryUrl) return <span style={{ color: "var(--text-muted)" }}>-</span>;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => downloadBinary(e, row)}
|
||||||
|
className="underline text-xs text-left"
|
||||||
|
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
||||||
|
title={binaryUrl}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "dateCreated":
|
||||||
|
return metadata.dateCreated ? new Date(metadata.dateCreated).toLocaleString() : "-";
|
||||||
|
case "dateEdited":
|
||||||
|
return metadata.dateEdited ? new Date(metadata.dateEdited).toLocaleString() : "-";
|
||||||
|
case "createdBy":
|
||||||
|
return metadata.createdBy || "-";
|
||||||
|
case "lastEditedBy":
|
||||||
|
return metadata.lastEditedBy || "-";
|
||||||
case "isTrueRing":
|
case "isTrueRing":
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -272,9 +479,7 @@ export default function MelodyList() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "docId":
|
case "docId":
|
||||||
return (
|
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{row.id}</span>;
|
||||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{row.id}</span>
|
|
||||||
);
|
|
||||||
case "pid":
|
case "pid":
|
||||||
return row.pid || "-";
|
return row.pid || "-";
|
||||||
default:
|
default:
|
||||||
@@ -283,14 +488,11 @@ export default function MelodyList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build visible column list (description is rendered inside name, not as its own column)
|
// Build visible column list (description is rendered inside name, not as its own column)
|
||||||
const activeColumns = ALL_COLUMNS.filter(
|
const activeColumns = ALL_COLUMNS.filter((c) => c.key !== "description" && isVisible(c.key));
|
||||||
(c) => c.key !== "description" && isVisible(c.key)
|
|
||||||
);
|
|
||||||
|
|
||||||
const languages = melodySettings?.available_languages || ["en"];
|
const languages = melodySettings?.available_languages || ["en"];
|
||||||
|
|
||||||
const selectClass =
|
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
|
||||||
"px-3 py-2 rounded-md text-sm cursor-pointer border";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -300,7 +502,7 @@ export default function MelodyList() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => navigate("/melodies/new")}
|
onClick={() => navigate("/melodies/new")}
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors cursor-pointer"
|
className="px-4 py-2 text-sm rounded-md transition-colors cursor-pointer"
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
>
|
>
|
||||||
Add Melody
|
Add Melody
|
||||||
</button>
|
</button>
|
||||||
@@ -325,6 +527,7 @@ export default function MelodyList() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={toneFilter}
|
value={toneFilter}
|
||||||
onChange={(e) => setToneFilter(e.target.value)}
|
onChange={(e) => setToneFilter(e.target.value)}
|
||||||
@@ -337,6 +540,7 @@ export default function MelodyList() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
@@ -346,6 +550,75 @@ export default function MelodyList() {
|
|||||||
<option value="published">Published (Live)</option>
|
<option value="published">Published (Live)</option>
|
||||||
<option value="draft">Drafts</option>
|
<option value="draft">Drafts</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="dateCreated">Sort: Date Created</option>
|
||||||
|
<option value="dateEdited">Sort: Date Edited</option>
|
||||||
|
<option value="name">Sort: Name</option>
|
||||||
|
<option value="totalBells">Sort: Total Bells</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortDir}
|
||||||
|
onChange={(e) => setSortDir(e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="desc">Desc</option>
|
||||||
|
<option value="asc">Asc</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div className="relative" ref={creatorPickerRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreatorPicker((prev) => !prev)}
|
||||||
|
className="px-3 py-2 rounded-md text-sm transition-colors cursor-pointer flex items-center gap-1.5 border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Created By
|
||||||
|
{createdByFilter.length > 0 ? ` (${createdByFilter.length})` : ""}
|
||||||
|
</button>
|
||||||
|
{showCreatorPicker && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 top-full mt-1 z-20 rounded-lg shadow-lg py-2 w-64 border max-h-64 overflow-auto"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
{allCreators.length === 0 ? (
|
||||||
|
<p className="px-3 py-1.5 text-sm" style={{ color: "var(--text-muted)" }}>No creators found</p>
|
||||||
|
) : (
|
||||||
|
allCreators.map((creator) => (
|
||||||
|
<label
|
||||||
|
key={creator}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={createdByFilter.includes(creator)}
|
||||||
|
onChange={() => toggleCreator(creator)}
|
||||||
|
className="h-3.5 w-3.5 rounded cursor-pointer"
|
||||||
|
/>
|
||||||
|
{creator}
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{createdByFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCreatedByFilter([])}
|
||||||
|
className="w-full text-left px-3 py-1.5 text-xs underline"
|
||||||
|
style={{ color: "var(--accent)", background: "none", border: "none" }}
|
||||||
|
>
|
||||||
|
Clear selection
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{languages.length > 1 && (
|
{languages.length > 1 && (
|
||||||
<select
|
<select
|
||||||
value={displayLang}
|
value={displayLang}
|
||||||
@@ -378,7 +651,7 @@ export default function MelodyList() {
|
|||||||
</button>
|
</button>
|
||||||
{showColumnPicker && (
|
{showColumnPicker && (
|
||||||
<div
|
<div
|
||||||
className="absolute right-0 top-full mt-1 z-20 rounded-lg shadow-lg py-2 w-52 border"
|
className="absolute right-0 top-full mt-1 z-20 rounded-lg shadow-lg py-2 w-56 border"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
@@ -388,9 +661,7 @@ export default function MelodyList() {
|
|||||||
<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"
|
||||||
style={{
|
style={{ color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)" }}
|
||||||
color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -407,7 +678,7 @@ export default function MelodyList() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
{total} {total === 1 ? "melody" : "melodies"}
|
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,7 +698,7 @@ export default function MelodyList() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||||
) : melodies.length === 0 ? (
|
) : displayRows.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-8 text-center text-sm border"
|
className="rounded-lg p-8 text-center text-sm border"
|
||||||
style={{
|
style={{
|
||||||
@@ -453,21 +724,19 @@ export default function MelodyList() {
|
|||||||
{activeColumns.map((col) => (
|
{activeColumns.map((col) => (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={`px-4 py-3 text-left font-medium ${
|
className={`px-4 py-3 text-left font-medium ${col.key === "color" ? "w-8 px-2" : ""}`}
|
||||||
col.key === "color" ? "w-8 px-2" : ""
|
|
||||||
}`}
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
style={{ color: "var(--text-muted)" }}
|
||||||
>
|
>
|
||||||
{col.key === "color" ? "" : col.label}
|
{col.key === "color" ? "" : col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<th className="px-4 py-3 text-left font-medium w-24" style={{ color: "var(--text-muted)" }} />
|
<th className="px-4 py-3 text-right font-medium w-48" style={{ color: "var(--text-muted)" }} />
|
||||||
)}
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{melodies.map((row) => (
|
{displayRows.map((row) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.id}
|
key={row.id}
|
||||||
onClick={() => navigate(`/melodies/${row.id}`)}
|
onClick={() => navigate(`/melodies/${row.id}`)}
|
||||||
@@ -479,9 +748,7 @@ export default function MelodyList() {
|
|||||||
{activeColumns.map((col) => (
|
{activeColumns.map((col) => (
|
||||||
<td
|
<td
|
||||||
key={col.key}
|
key={col.key}
|
||||||
className={`px-4 py-3 ${
|
className={`px-4 py-3 ${col.key === "color" ? "w-8 px-2" : ""}`}
|
||||||
col.key === "color" ? "w-8 px-2" : ""
|
|
||||||
}`}
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
style={{ color: "var(--text-primary)" }}
|
||||||
>
|
>
|
||||||
{renderCellValue(col.key, row)}
|
{renderCellValue(col.key, row)}
|
||||||
@@ -489,16 +756,13 @@ export default function MelodyList() {
|
|||||||
))}
|
))}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div
|
<div className="flex gap-2 justify-end" onClick={(e) => e.stopPropagation()}>
|
||||||
className="flex gap-2"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{row.status === "draft" ? (
|
{row.status === "draft" ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePublish(row)}
|
onClick={() => handlePublish(row)}
|
||||||
disabled={actionLoading === row.id}
|
disabled={actionLoading === row.id}
|
||||||
className="text-xs cursor-pointer disabled:opacity-50"
|
className="text-xs cursor-pointer disabled:opacity-50 px-2 py-1 rounded-full"
|
||||||
style={{ color: "#22c55e" }}
|
style={{ color: "#22c55e", backgroundColor: "rgba(34,197,94,0.15)" }}
|
||||||
>
|
>
|
||||||
{actionLoading === row.id ? "..." : "Publish"}
|
{actionLoading === row.id ? "..." : "Publish"}
|
||||||
</button>
|
</button>
|
||||||
@@ -506,23 +770,23 @@ export default function MelodyList() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => setUnpublishTarget(row)}
|
onClick={() => setUnpublishTarget(row)}
|
||||||
disabled={actionLoading === row.id}
|
disabled={actionLoading === row.id}
|
||||||
className="text-xs cursor-pointer disabled:opacity-50"
|
className="text-xs cursor-pointer disabled:opacity-50 px-2 py-1 rounded-full"
|
||||||
style={{ color: "#ea580c" }}
|
style={{ color: "#ea580c", backgroundColor: "rgba(234,88,12,0.16)" }}
|
||||||
>
|
>
|
||||||
Unpublish
|
Unpublish
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/melodies/${row.id}/edit`)}
|
onClick={() => navigate(`/melodies/${row.id}/edit`)}
|
||||||
className="text-xs cursor-pointer"
|
className="text-xs cursor-pointer px-2 py-1 rounded-full"
|
||||||
style={{ color: "var(--text-link)" }}
|
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(row)}
|
onClick={() => setDeleteTarget(row)}
|
||||||
className="text-xs cursor-pointer"
|
className="text-xs cursor-pointer px-2 py-1 rounded-full"
|
||||||
style={{ color: "var(--danger)" }}
|
style={{ color: "var(--danger)", backgroundColor: "rgba(243,75,75,0.14)" }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user