CODEX - Further changes performed
This commit is contained in:
@@ -355,17 +355,8 @@ export default function MelodyComposer() {
|
||||
className="rounded-lg border p-4"
|
||||
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="xl:col-span-3">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
@@ -385,19 +376,19 @@ export default function MelodyComposer() {
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDeployModal}
|
||||
className="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
|
||||
>
|
||||
Deploy as Archetype
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-5">
|
||||
<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
|
||||
@@ -417,7 +408,7 @@ export default function MelodyComposer() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-4">
|
||||
<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
|
||||
@@ -436,7 +427,19 @@ export default function MelodyComposer() {
|
||||
className="w-full mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||
<div className="ml-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDeployModal}
|
||||
className="px-4 py-2 rounded-md text-sm whitespace-nowrap"
|
||||
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
|
||||
>
|
||||
Deploy Archetype
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep >= 0 && (
|
||||
@@ -522,13 +525,13 @@ export default function MelodyComposer() {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: "64%",
|
||||
height: "64%",
|
||||
width: "54%",
|
||||
height: "54%",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "var(--btn-primary)",
|
||||
opacity: enabled ? 1 : 0,
|
||||
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",
|
||||
}}
|
||||
/>
|
||||
@@ -588,7 +591,7 @@ export default function MelodyComposer() {
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||
Deploy as Archetype
|
||||
Deploy Archetype
|
||||
</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
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 api from "../api/client";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
@@ -13,16 +13,18 @@ import {
|
||||
|
||||
const MELODY_TYPES = ["", "orthodox", "catholic", "all"];
|
||||
const MELODY_TONES = ["", "normal", "festive", "cheerful", "lamentation"];
|
||||
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||
|
||||
// All available columns with their defaults
|
||||
const ALL_COLUMNS = [
|
||||
{ key: "color", label: "Color", defaultOn: true },
|
||||
{ key: "status", label: "Status", defaultOn: true },
|
||||
{ key: "name", label: "Name", defaultOn: true, alwaysOn: true },
|
||||
{ key: "status", label: "Status", defaultOn: true },
|
||||
{ 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: "minSpeed", label: "Min Speed", defaultOn: false },
|
||||
{ key: "maxSpeed", label: "Max Speed", defaultOn: false },
|
||||
{ key: "tags", label: "Tags", defaultOn: false },
|
||||
@@ -32,6 +34,11 @@ const ALL_COLUMNS = [
|
||||
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
||||
{ key: "infiniteLoop", label: "Infinite", 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: "docId", label: "Document ID", 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);
|
||||
}
|
||||
|
||||
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() {
|
||||
const [melodies, setMelodies] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -58,6 +102,9 @@ export default function MelodyList() {
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [toneFilter, setToneFilter] = 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 [melodySettings, setMelodySettings] = useState(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
@@ -65,7 +112,9 @@ export default function MelodyList() {
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
||||
const columnPickerRef = useRef(null);
|
||||
const creatorPickerRef = useRef(null);
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
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(() => {
|
||||
const handleClick = (e) => {
|
||||
if (columnPickerRef.current && !columnPickerRef.current.contains(e.target)) {
|
||||
setShowColumnPicker(false);
|
||||
}
|
||||
if (creatorPickerRef.current && !creatorPickerRef.current.contains(e.target)) {
|
||||
setShowCreatorPicker(false);
|
||||
}
|
||||
};
|
||||
if (showColumnPicker) {
|
||||
if (showColumnPicker || showCreatorPicker) {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}
|
||||
}, [showColumnPicker]);
|
||||
}, [showColumnPicker, showCreatorPicker]);
|
||||
|
||||
const fetchMelodies = async () => {
|
||||
setLoading(true);
|
||||
@@ -101,8 +153,8 @@ export default function MelodyList() {
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
const qs = params.toString();
|
||||
const data = await api.get(`/melodies${qs ? `?${qs}` : ""}`);
|
||||
setMelodies(data.melodies);
|
||||
setTotal(data.total);
|
||||
setMelodies(data.melodies || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} 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 col = ALL_COLUMNS.find((c) => c.key === key);
|
||||
if (col?.alwaysOn) return;
|
||||
@@ -165,20 +245,73 @@ 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 getDisplayName = (nameVal) =>
|
||||
getLocalizedValue(nameVal, displayLang, "Untitled");
|
||||
const getDisplayName = (nameVal) => 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 info = row.information || {};
|
||||
const ds = row.default_settings || {};
|
||||
const metadata = row.metadata || {};
|
||||
|
||||
switch (key) {
|
||||
case "status":
|
||||
return (
|
||||
<span
|
||||
className="px-2 py-0.5 text-xs font-semibold rounded-full"
|
||||
style={row.status === "published"
|
||||
style={
|
||||
row.status === "published"
|
||||
? { backgroundColor: "rgba(22,163,74,0.15)", color: "#22c55e" }
|
||||
: { backgroundColor: "rgba(156,163,175,0.15)", color: "#9ca3af" }
|
||||
}
|
||||
@@ -196,25 +329,33 @@ export default function MelodyList() {
|
||||
) : (
|
||||
<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 (
|
||||
<div>
|
||||
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
|
||||
{getDisplayName(info.name)}
|
||||
</span>
|
||||
{isVisible("description") && (
|
||||
<p className="text-xs mt-0.5 truncate max-w-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{getLocalizedValue(info.description, displayLang) || "-"}
|
||||
<p
|
||||
className="text-xs mt-0.5 truncate max-w-xs"
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "type":
|
||||
return <span className="capitalize">{row.type}</span>;
|
||||
case "tone":
|
||||
return <span className="capitalize">{info.melodyTone || "-"}</span>;
|
||||
case "totalNotes":
|
||||
return info.totalNotes ?? "-";
|
||||
case "totalActiveBells":
|
||||
return info.totalActiveBells ?? "-";
|
||||
case "minSpeed":
|
||||
return info.minSpeed ?? "-";
|
||||
case "maxSpeed":
|
||||
@@ -236,7 +377,26 @@ export default function MelodyList() {
|
||||
"-"
|
||||
);
|
||||
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":
|
||||
return ds.duration != null ? formatDuration(ds.duration) : "-";
|
||||
case "totalRunDuration":
|
||||
@@ -256,9 +416,56 @@ export default function MelodyList() {
|
||||
</span>
|
||||
);
|
||||
case "noteAssignments":
|
||||
return ds.noteAssignments?.length > 0
|
||||
? ds.noteAssignments.join(", ")
|
||||
: "-";
|
||||
return ds.noteAssignments?.length > 0 ? (
|
||||
<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":
|
||||
return (
|
||||
<span
|
||||
@@ -272,9 +479,7 @@ export default function MelodyList() {
|
||||
</span>
|
||||
);
|
||||
case "docId":
|
||||
return (
|
||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{row.id}</span>
|
||||
);
|
||||
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{row.id}</span>;
|
||||
case "pid":
|
||||
return row.pid || "-";
|
||||
default:
|
||||
@@ -283,14 +488,11 @@ 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)
|
||||
);
|
||||
const activeColumns = ALL_COLUMNS.filter((c) => c.key !== "description" && isVisible(c.key));
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -325,6 +527,7 @@ export default function MelodyList() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={toneFilter}
|
||||
onChange={(e) => setToneFilter(e.target.value)}
|
||||
@@ -337,6 +540,7 @@ export default function MelodyList() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
@@ -346,6 +550,75 @@ export default function MelodyList() {
|
||||
<option value="published">Published (Live)</option>
|
||||
<option value="draft">Drafts</option>
|
||||
</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 && (
|
||||
<select
|
||||
value={displayLang}
|
||||
@@ -378,7 +651,7 @@ export default function MelodyList() {
|
||||
</button>
|
||||
{showColumnPicker && (
|
||||
<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={{
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderColor: "var(--border-primary)",
|
||||
@@ -388,9 +661,7 @@ export default function MelodyList() {
|
||||
<label
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
|
||||
style={{
|
||||
color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)",
|
||||
}}
|
||||
style={{ color: col.alwaysOn ? "var(--text-muted)" : "var(--text-primary)" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -407,7 +678,7 @@ export default function MelodyList() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -427,7 +698,7 @@ export default function MelodyList() {
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||
) : melodies.length === 0 ? (
|
||||
) : displayRows.length === 0 ? (
|
||||
<div
|
||||
className="rounded-lg p-8 text-center text-sm border"
|
||||
style={{
|
||||
@@ -453,21 +724,19 @@ export default function MelodyList() {
|
||||
{activeColumns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-4 py-3 text-left font-medium ${
|
||||
col.key === "color" ? "w-8 px-2" : ""
|
||||
}`}
|
||||
className={`px-4 py-3 text-left font-medium ${col.key === "color" ? "w-8 px-2" : ""}`}
|
||||
style={{ color: "var(--text-muted)" }}
|
||||
>
|
||||
{col.key === "color" ? "" : col.label}
|
||||
</th>
|
||||
))}
|
||||
{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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{melodies.map((row) => (
|
||||
{displayRows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
onClick={() => navigate(`/melodies/${row.id}`)}
|
||||
@@ -479,9 +748,7 @@ export default function MelodyList() {
|
||||
{activeColumns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-4 py-3 ${
|
||||
col.key === "color" ? "w-8 px-2" : ""
|
||||
}`}
|
||||
className={`px-4 py-3 ${col.key === "color" ? "w-8 px-2" : ""}`}
|
||||
style={{ color: "var(--text-primary)" }}
|
||||
>
|
||||
{renderCellValue(col.key, row)}
|
||||
@@ -489,16 +756,13 @@ export default function MelodyList() {
|
||||
))}
|
||||
{canEdit && (
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className="flex gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex gap-2 justify-end" onClick={(e) => e.stopPropagation()}>
|
||||
{row.status === "draft" ? (
|
||||
<button
|
||||
onClick={() => handlePublish(row)}
|
||||
disabled={actionLoading === row.id}
|
||||
className="text-xs cursor-pointer disabled:opacity-50"
|
||||
style={{ color: "#22c55e" }}
|
||||
className="text-xs cursor-pointer disabled:opacity-50 px-2 py-1 rounded-full"
|
||||
style={{ color: "#22c55e", backgroundColor: "rgba(34,197,94,0.15)" }}
|
||||
>
|
||||
{actionLoading === row.id ? "..." : "Publish"}
|
||||
</button>
|
||||
@@ -506,23 +770,23 @@ export default function MelodyList() {
|
||||
<button
|
||||
onClick={() => setUnpublishTarget(row)}
|
||||
disabled={actionLoading === row.id}
|
||||
className="text-xs cursor-pointer disabled:opacity-50"
|
||||
style={{ color: "#ea580c" }}
|
||||
className="text-xs cursor-pointer disabled:opacity-50 px-2 py-1 rounded-full"
|
||||
style={{ color: "#ea580c", backgroundColor: "rgba(234,88,12,0.16)" }}
|
||||
>
|
||||
Unpublish
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/melodies/${row.id}/edit`)}
|
||||
className="text-xs cursor-pointer"
|
||||
style={{ color: "var(--text-link)" }}
|
||||
className="text-xs cursor-pointer px-2 py-1 rounded-full"
|
||||
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTarget(row)}
|
||||
className="text-xs cursor-pointer"
|
||||
style={{ color: "var(--danger)" }}
|
||||
className="text-xs cursor-pointer px-2 py-1 rounded-full"
|
||||
style={{ color: "var(--danger)", backgroundColor: "rgba(243,75,75,0.14)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user