fix: Various fixes. Mail, UI, Flash etc

This commit is contained in:
2026-02-27 14:32:24 +02:00
parent 7585e43b52
commit 810e81b323
9 changed files with 930 additions and 612 deletions

View File

@@ -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>