fix: Various fixes. Mail, UI, Flash etc
This commit is contained in:
@@ -3,15 +3,51 @@ import { useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
|
||||
const BOARD_TYPES = [
|
||||
{ value: "vs", name: "VESPER", codename: "vesper-basic" },
|
||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus" },
|
||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro" },
|
||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic" },
|
||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" },
|
||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini" },
|
||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic" },
|
||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" },
|
||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" },
|
||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" },
|
||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" },
|
||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" },
|
||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" },
|
||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" },
|
||||
];
|
||||
|
||||
const BOARD_FAMILY_COLORS = {
|
||||
vesper: { selectedBg: "#0a1929", selectedBorder: "#3b82f6", selectedText: "#60a5fa", hoverBorder: "#3b82f6", glowColor: "rgba(59,130,246,0.35)", idleBorder: "#1d3a5c", idleText: "#7ca8d4" },
|
||||
agnus: { selectedBg: "#1a1400", selectedBorder: "#f59e0b", selectedText: "#fbbf24", hoverBorder: "#f59e0b", glowColor: "rgba(245,158,11,0.35)", idleBorder: "#4a3800", idleText: "#c79d3a" },
|
||||
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
|
||||
};
|
||||
|
||||
function BoardTile({ bt, isSelected, onClick }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const pal = BOARD_FAMILY_COLORS[bt.family];
|
||||
const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder;
|
||||
const boxShadow = isSelected
|
||||
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
|
||||
: hovered ? `0 0 12px 3px ${pal.glowColor}` : "none";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className="rounded-lg border p-3 text-left cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card)",
|
||||
borderColor, boxShadow,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
|
||||
}}
|
||||
>
|
||||
<p className="font-bold text-xs tracking-wide"
|
||||
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}>
|
||||
{bt.name}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5 font-mono opacity-60" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
|
||||
<p className="text-xs mt-1 leading-snug" style={{ color: "var(--text-muted)", opacity: 0.75 }}>{bt.desc}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BatchCreator() {
|
||||
const navigate = useNavigate();
|
||||
const [boardType, setBoardType] = useState(null);
|
||||
@@ -57,7 +93,7 @@ export default function BatchCreator() {
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--text-heading)" }}>
|
||||
<h1 className="text-2xl font-bold mb-6" style={{ color: "var(--text-heading)" }}>
|
||||
New Batch
|
||||
</h1>
|
||||
|
||||
@@ -78,36 +114,34 @@ export default function BatchCreator() {
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Board Type tiles */}
|
||||
{/* Board Type tiles — Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
|
||||
Board Type
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{BOARD_TYPES.map((bt) => {
|
||||
const isSel = boardType === bt.value;
|
||||
return (
|
||||
<button
|
||||
key={bt.value}
|
||||
type="button"
|
||||
onClick={() => setBoardType(bt.value)}
|
||||
className="rounded-lg border p-3 text-left cursor-pointer transition-all"
|
||||
style={{
|
||||
backgroundColor: isSel ? "#0a1f0a" : "var(--bg-card-hover)",
|
||||
borderColor: isSel ? "#22c55e" : "var(--border-primary)",
|
||||
boxShadow: isSel ? "0 0 0 1px #22c55e" : "none",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs font-bold tracking-wide"
|
||||
style={{ color: isSel ? "#22c55e" : "var(--text-heading)" }}>
|
||||
{bt.name}
|
||||
</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
{bt.codename}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2">
|
||||
{/* Row 1: Vesper family */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
{BOARD_TYPES.filter((b) => b.family === "vesper").map((bt) => (
|
||||
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
||||
))}
|
||||
</div>
|
||||
{/* Row 2: Agnus family — 2 cols left-aligned */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{BOARD_TYPES.filter((b) => b.family === "agnus").map((bt) => (
|
||||
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 3: Chronos family — 2 cols left-aligned */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{BOARD_TYPES.filter((b) => b.family === "chronos").map((bt) => (
|
||||
<BoardTile key={bt.value} bt={bt} isSelected={boardType === bt.value} onClick={() => setBoardType(bt.value)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,16 +5,23 @@ import api from "../api/client";
|
||||
|
||||
// ─── constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||
const BOARD_TYPES = [
|
||||
{ value: "vs", name: "VESPER", codename: "vesper-basic" },
|
||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus" },
|
||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro" },
|
||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic" },
|
||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" },
|
||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini" },
|
||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic" },
|
||||
{ value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
||||
{ value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
||||
{ value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
||||
{ value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
||||
{ value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
||||
{ value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
||||
{ value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
||||
];
|
||||
|
||||
const BOARD_FAMILY_COLORS = {
|
||||
vesper: { selectedBg: "#0a1929", selectedBorder: "#3b82f6", selectedText: "#60a5fa", hoverBorder: "#3b82f6", glowColor: "rgba(59,130,246,0.35)", idleBorder: "#1d3a5c", idleText: "#7ca8d4" },
|
||||
agnus: { selectedBg: "#1a1400", selectedBorder: "#f59e0b", selectedText: "#fbbf24", hoverBorder: "#f59e0b", glowColor: "rgba(245,158,11,0.35)", idleBorder: "#4a3800", idleText: "#c79d3a" },
|
||||
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
|
||||
};
|
||||
|
||||
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
|
||||
|
||||
const STATUS_STYLES = {
|
||||
@@ -188,6 +195,35 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Board Type Tile ──────────────────────────────────────────────────────────
|
||||
|
||||
function BoardTypeTile({ bt, isSelected, pal, onClick }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder;
|
||||
const boxShadow = isSelected
|
||||
? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}`
|
||||
: hovered ? `0 0 12px 3px ${pal.glowColor}` : "none";
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className="rounded-lg border p-3 text-left cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: isSelected ? pal.selectedBg : "var(--bg-card-hover)",
|
||||
borderColor, boxShadow,
|
||||
transition: "border-color 0.15s, box-shadow 0.15s, background-color 0.15s",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs font-bold tracking-wide"
|
||||
style={{ color: isSelected ? pal.selectedText : hovered ? pal.idleText : "var(--text-heading)" }}>
|
||||
{bt.name}
|
||||
</p>
|
||||
<p className="text-xs font-mono mt-0.5 opacity-60" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Add Device Modal ─────────────────────────────────────────────────────────
|
||||
|
||||
function AddDeviceModal({ onClose, onCreated }) {
|
||||
@@ -222,26 +258,29 @@ function AddDeviceModal({ onClose, onCreated }) {
|
||||
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>Add Single Device</h2>
|
||||
|
||||
<p className="text-sm mb-3" style={{ color: "var(--text-secondary)" }}>Board Type</p>
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{BOARD_TYPES.map((bt) => {
|
||||
const isSel = boardType === bt.value;
|
||||
return (
|
||||
<button
|
||||
key={bt.value}
|
||||
onClick={() => setBoardType(bt.value)}
|
||||
className="rounded-lg border p-3 text-left cursor-pointer transition-all"
|
||||
style={{
|
||||
backgroundColor: isSel ? "#0a1f0a" : "var(--bg-card-hover)",
|
||||
borderColor: isSel ? "#22c55e" : "var(--border-primary)",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs font-bold tracking-wide" style={{ color: isSel ? "#22c55e" : "var(--text-heading)" }}>
|
||||
{bt.name}
|
||||
</p>
|
||||
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>{bt.codename}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="space-y-2" style={{ marginBottom: 16 }}>
|
||||
{/* Row 1: Vesper family — 3 columns */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
{BOARD_TYPES.filter((b) => b.family === "vesper").map((bt) => (
|
||||
<BoardTypeTile key={bt.value} bt={bt} isSelected={boardType === bt.value} pal={BOARD_FAMILY_COLORS[bt.family]} onClick={() => setBoardType(bt.value)} />
|
||||
))}
|
||||
</div>
|
||||
{/* Row 2: Agnus family — 2 columns (left-aligned in 3-col grid) */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{BOARD_TYPES.filter((b) => b.family === "agnus").map((bt) => (
|
||||
<BoardTypeTile key={bt.value} bt={bt} isSelected={boardType === bt.value} pal={BOARD_FAMILY_COLORS[bt.family]} onClick={() => setBoardType(bt.value)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Row 3: Chronos family — 2 columns (left-aligned in 3-col grid) */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
|
||||
<div style={{ gridColumn: "1 / 3", display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
{BOARD_TYPES.filter((b) => b.family === "chronos").map((bt) => (
|
||||
<BoardTypeTile key={bt.value} bt={bt} isSelected={boardType === bt.value} pal={BOARD_FAMILY_COLORS[bt.family]} onClick={() => setBoardType(bt.value)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||
|
||||
@@ -291,7 +291,7 @@ export default function DeviceInventoryDetail() {
|
||||
{/* Title row */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
||||
<h1 className="text-2xl font-bold font-mono" style={{ color: "var(--text-heading)" }}>
|
||||
{device?.serial_number}
|
||||
</h1>
|
||||
{device?.device_name && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,16 +51,128 @@ const ALL_COLUMNS = [
|
||||
{ key: "pid", label: "PID", defaultOn: false },
|
||||
];
|
||||
|
||||
function getDefaultVisibleColumns() {
|
||||
const saved = localStorage.getItem("melodyListColumns");
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved);
|
||||
} catch {
|
||||
// ignore
|
||||
const MEL_COL_VIS_KEY = "melodyListColumns";
|
||||
const MEL_COL_ORDER_KEY = "melodyListColumnsOrder";
|
||||
|
||||
function loadColumnPrefs() {
|
||||
try {
|
||||
const vis = JSON.parse(localStorage.getItem(MEL_COL_VIS_KEY) || "null");
|
||||
const order = JSON.parse(localStorage.getItem(MEL_COL_ORDER_KEY) || "null");
|
||||
const visible = vis
|
||||
? Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, vis.includes ? vis.includes(c.key) : Boolean(vis[c.key])]))
|
||||
: Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, c.defaultOn]));
|
||||
const orderedIds = order || ALL_COLUMNS.map((c) => c.key);
|
||||
// always-on columns
|
||||
for (const c of ALL_COLUMNS) {
|
||||
if (c.alwaysOn) visible[c.key] = true;
|
||||
if (!orderedIds.includes(c.key)) orderedIds.push(c.key);
|
||||
}
|
||||
return { visible, orderedIds };
|
||||
} catch {
|
||||
return {
|
||||
visible: Object.fromEntries(ALL_COLUMNS.map((c) => [c.key, c.defaultOn])),
|
||||
orderedIds: ALL_COLUMNS.map((c) => c.key),
|
||||
};
|
||||
}
|
||||
return ALL_COLUMNS.filter((c) => c.defaultOn).map((c) => c.key);
|
||||
}
|
||||
|
||||
function saveColumnPrefs(visible, orderedIds) {
|
||||
localStorage.setItem(MEL_COL_VIS_KEY, JSON.stringify(visible));
|
||||
localStorage.setItem(MEL_COL_ORDER_KEY, JSON.stringify(orderedIds));
|
||||
}
|
||||
|
||||
// ─── Melody Column Toggle (drag-and-drop) ─────────────────────────────────────
|
||||
|
||||
function MelodyColumnToggle({ visible, orderedIds, onChange, onReorder }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const handleDragStart = (id) => setDragging(id);
|
||||
const handleDragOver = (e, id) => {
|
||||
e.preventDefault();
|
||||
if (dragging && dragging !== id) {
|
||||
const next = [...orderedIds];
|
||||
const from = next.indexOf(dragging);
|
||||
const to = next.indexOf(id);
|
||||
next.splice(from, 1);
|
||||
next.splice(to, 0, dragging);
|
||||
onReorder(next);
|
||||
}
|
||||
};
|
||||
const handleDragEnd = () => setDragging(null);
|
||||
|
||||
const visibleCount = Object.values(visible).filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md transition-colors cursor-pointer border"
|
||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
Columns ({visibleCount})
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-[9999] rounded-lg border shadow-lg p-2 overflow-y-auto"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", width: 220, maxHeight: 420 }}
|
||||
>
|
||||
<p className="text-xs font-medium px-2 py-1 mb-1" style={{ color: "var(--text-muted)" }}>
|
||||
Drag to reorder
|
||||
</p>
|
||||
{orderedIds.map((key) => {
|
||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||
if (!col) return null;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
draggable={!col.alwaysOn}
|
||||
onDragStart={() => !col.alwaysOn && handleDragStart(key)}
|
||||
onDragOver={(e) => handleDragOver(e, key)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded select-none"
|
||||
style={{
|
||||
cursor: col.alwaysOn ? "default" : "grab",
|
||||
backgroundColor: dragging === key ? "var(--bg-card-hover)" : "transparent",
|
||||
opacity: dragging === key ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-muted)", fontSize: 10, opacity: col.alwaysOn ? 0.3 : 1 }}>⠿</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!visible[key]}
|
||||
disabled={col.alwaysOn}
|
||||
onChange={(e) => onChange(key, e.target.checked)}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span
|
||||
className="text-sm"
|
||||
style={{ color: col.alwaysOn ? "var(--text-muted)" : "var(--text-secondary)" }}
|
||||
>
|
||||
{col.label}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function speedBarColor(speedPercent) {
|
||||
@@ -258,15 +370,35 @@ export default function MelodyList() {
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
const [unpublishTarget, setUnpublishTarget] = useState(null);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
|
||||
const [showColumnPicker, setShowColumnPicker] = useState(false);
|
||||
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
||||
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
||||
const [showOfflineModal, setShowOfflineModal] = useState(false);
|
||||
const [builtInSavingId, setBuiltInSavingId] = useState(null);
|
||||
const [viewRow, setViewRow] = useState(null);
|
||||
const [builtMap, setBuiltMap] = useState({});
|
||||
const columnPickerRef = useRef(null);
|
||||
const creatorPickerRef = useRef(null);
|
||||
|
||||
// Derived helpers from colPrefs
|
||||
const visibleColumns = colPrefs.orderedIds.filter((k) => colPrefs.visible[k]);
|
||||
const isVisible = (key) => Boolean(colPrefs.visible[key]);
|
||||
|
||||
const handleColVisChange = (key, checked) => {
|
||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||
if (col?.alwaysOn) return;
|
||||
setColPrefs((prev) => {
|
||||
const next = { ...prev, visible: { ...prev.visible, [key]: checked } };
|
||||
saveColumnPrefs(next.visible, next.orderedIds);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleColReorder = (orderedIds) => {
|
||||
setColPrefs((prev) => {
|
||||
const next = { ...prev, orderedIds };
|
||||
saveColumnPrefs(next.visible, next.orderedIds);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = hasPermission("melodies", "edit");
|
||||
@@ -278,35 +410,18 @@ 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 creator picker 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 || showCreatorPicker) {
|
||||
if (showCreatorPicker) {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}
|
||||
}, [showColumnPicker, showCreatorPicker]);
|
||||
}, [showCreatorPicker]);
|
||||
|
||||
const fetchMelodies = async () => {
|
||||
setLoading(true);
|
||||
@@ -519,45 +634,12 @@ export default function MelodyList() {
|
||||
}
|
||||
};
|
||||
|
||||
const toggleColumn = (key) => {
|
||||
const col = ALL_COLUMNS.find((c) => c.key === key);
|
||||
if (col?.alwaysOn) return;
|
||||
setVisibleColumns((prev) => {
|
||||
const next = prev.includes(key)
|
||||
? prev.filter((k) => k !== key)
|
||||
: [...prev, key];
|
||||
localStorage.setItem("melodyListColumns", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const moveColumn = (key, direction) => {
|
||||
setVisibleColumns((prev) => {
|
||||
const idx = prev.indexOf(key);
|
||||
if (idx < 0) return prev;
|
||||
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
|
||||
if (swapIdx < 0 || swapIdx >= prev.length) return prev;
|
||||
const next = [...prev];
|
||||
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
|
||||
localStorage.setItem("melodyListColumns", JSON.stringify(next));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCreator = (creator) => {
|
||||
setCreatedByFilter((prev) =>
|
||||
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
||||
);
|
||||
};
|
||||
|
||||
const isVisible = (key) => visibleColumns.includes(key);
|
||||
const orderedColumnPickerColumns = useMemo(() => {
|
||||
const byKey = new Map(ALL_COLUMNS.map((c) => [c.key, c]));
|
||||
const visibleOrdered = visibleColumns.map((k) => byKey.get(k)).filter(Boolean);
|
||||
const hidden = ALL_COLUMNS.filter((c) => !visibleColumns.includes(c.key));
|
||||
return [...visibleOrdered, ...hidden];
|
||||
}, [visibleColumns]);
|
||||
|
||||
const getDisplayName = (nameVal) => getLocalizedValue(nameVal, displayLang, "Untitled");
|
||||
|
||||
const allCreators = useMemo(() => {
|
||||
@@ -1091,74 +1173,13 @@ export default function MelodyList() {
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="relative" ref={columnPickerRef} style={{ zIndex: 60 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColumnPicker((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)",
|
||||
}}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" />
|
||||
</svg>
|
||||
Columns
|
||||
</button>
|
||||
{showColumnPicker && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 rounded-lg shadow-lg py-2 w-56 border"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-card)",
|
||||
borderColor: "var(--border-primary)",
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
{orderedColumnPickerColumns.map((col) => {
|
||||
const orderIdx = visibleColumns.indexOf(col.key);
|
||||
const canMove = orderIdx >= 0;
|
||||
return (
|
||||
<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)" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isVisible(col.key)}
|
||||
onChange={() => toggleColumn(col.key)}
|
||||
disabled={col.alwaysOn}
|
||||
className="h-3.5 w-3.5 rounded cursor-pointer"
|
||||
/>
|
||||
<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 up"
|
||||
>
|
||||
↑
|
||||
</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 down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ zIndex: 60, position: "relative" }}>
|
||||
<MelodyColumnToggle
|
||||
visible={colPrefs.visible}
|
||||
orderedIds={colPrefs.orderedIds}
|
||||
onChange={handleColVisChange}
|
||||
onReorder={handleColReorder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user