feat: add compact account management actions

This commit is contained in:
Sucukdeluxe
2026-08-09 15:18:24 +02:00
parent f84c1f0edd
commit 41b5d62d14
5 changed files with 275 additions and 112 deletions
+6
View File
@@ -10,6 +10,12 @@ Desktop downloader for Windows with package-based queue management, multi-provid
## Changelog
### 2.0.8
- Add a compact account-management table with traffic, status, expiry, username, and credential columns.
- Add account actions through right-click and the row action menu.
- Support quick edit, status checks, enable/disable, and removal without expanding the full account form.
### 2.0.7
- Improve account filter readability in dark and light themes.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "real-debrid-downloader",
"version": "2.0.7",
"version": "2.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "real-debrid-downloader",
"version": "2.0.7",
"version": "2.0.8",
"license": "MIT",
"dependencies": {
"adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "2.0.7",
"version": "2.0.8",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
+247 -97
View File
@@ -148,7 +148,7 @@ interface DebridLinkAccountKeyEntry {
dailyLimitReached: boolean;
}
interface ConfiguredAccountEntry {
interface ConfiguredAccountEntry {
kind: AccountKind;
service: AccountService;
provider: DebridProvider;
@@ -164,8 +164,33 @@ interface ConfiguredAccountEntry {
dailyLimitBytes: number;
dailyRemainingBytes: number | null;
dailyLimitReached: boolean;
debridLinkKeys: DebridLinkAccountKeyEntry[];
}
debridLinkKeys: DebridLinkAccountKeyEntry[];
}
interface AccountTableRow {
rowKey: string;
entry: ConfiguredAccountEntry;
hosterLabel: string;
modeLabel: string;
username: string;
credentialLabel: string;
accountId: string | null;
checkable: boolean;
disabled: boolean;
dailyUsedBytes: number;
dailyLimitBytes: number;
dailyRemainingBytes: number;
totalUsedBytes: number;
toggleKind: "mega" | "dl" | "single";
megaLogin?: string;
dlKey?: DebridLinkAccountKeyEntry;
}
interface AccountContextMenuState {
x: number;
y: number;
row: AccountTableRow;
}
function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => {
@@ -422,7 +447,7 @@ function buildDebridLinkKeyLimitInputs(rawKeys: string, values?: Record<string,
return next;
}
function getAccountPickerFunctionLabel(option: AccountOption): string {
function getAccountPickerFunctionLabel(option: AccountOption): string {
switch (option.kind) {
case "realdebrid-api":
case "bestdebrid-api":
@@ -443,8 +468,30 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
return "Login + Passwort";
default:
return option.modeLabel;
}
}
}
}
function getAccountCredentialLabel(kind: AccountKind): string {
switch (kind) {
case "megadebrid-api":
case "megadebrid-web":
case "ddownload-login":
case "linksnappy-login":
return "••••••";
case "bestdebrid-web":
case "realdebrid-web":
case "alldebrid-web":
return "Login gespeichert";
case "realdebrid-api":
case "bestdebrid-api":
case "alldebrid-api":
case "onefichier-api":
case "debridlink-api":
return "API-Key gespeichert";
default:
return "Zugang gespeichert";
}
}
function hasMegaDebridCredentials(settings: AppSettings): boolean {
if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true;
@@ -1778,9 +1825,11 @@ export function App(): ReactElement {
const [rdName, setRdName] = useState("");
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const ctxMenuRef = useRef<HTMLDivElement>(null);
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const ctxMenuRef = useRef<HTMLDivElement>(null);
const [accountContextMenu, setAccountContextMenu] = useState<AccountContextMenuState | null>(null);
const accountContextMenuRef = useRef<HTMLDivElement>(null);
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
const [accountDialogSearch, setAccountDialogSearch] = useState("");
@@ -2551,25 +2600,8 @@ export function App(): ReactElement {
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]);
const accountRows = useMemo(() => {
type AccountRow = {
rowKey: string;
entry: ConfiguredAccountEntry;
hosterLabel: string;
modeLabel: string;
username: string;
accountId: string | null;
checkable: boolean;
disabled: boolean;
dailyUsedBytes: number;
dailyLimitBytes: number;
dailyRemainingBytes: number;
totalUsedBytes: number;
toggleKind: "mega" | "dl" | "single";
megaLogin?: string;
dlKey?: DebridLinkAccountKeyEntry;
};
const rows: AccountRow[] = [];
const accountRows = useMemo(() => {
const rows: AccountTableRow[] = [];
for (const entry of configuredAccounts) {
if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") {
const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "");
@@ -2579,10 +2611,11 @@ export function App(): ReactElement {
rows.push({
rowKey: `mega-${entry.kind}-${acc.id}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: acc.maskedLogin,
accountId: acc.id,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: acc.maskedLogin,
credentialLabel: "••••••",
accountId: acc.id,
checkable: true,
disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id),
dailyUsedBytes: used,
@@ -2598,10 +2631,11 @@ export function App(): ReactElement {
rows.push({
rowKey: `dl-${key.id}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: key.masked,
accountId: key.id,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: key.masked,
credentialLabel: "API-Key",
accountId: key.id,
checkable: true,
disabled: key.disabled,
dailyUsedBytes: key.dailyUsedBytes,
@@ -2616,10 +2650,11 @@ export function App(): ReactElement {
rows.push({
rowKey: `svc-${entry.service}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: entry.summary,
accountId: null,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: entry.summary,
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null,
checkable: false,
disabled: entry.disabled,
dailyUsedBytes: entry.dailyUsedBytes,
@@ -2687,13 +2722,16 @@ export function App(): ReactElement {
});
};
const renderAccountRow = (row: (typeof accountRows)[number], isMember: boolean): ReactElement => {
const renderAccountRow = (row: AccountTableRow, isMember: boolean): ReactElement => {
const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
let statusCls = "none";
let statusText = "—";
if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; }
else if (!row.checkable) { statusCls = "none"; statusText = "—"; }
else if (!row.checkable) {
statusCls = row.entry.statusLabel === "Aktiviert" ? "unknown" : "ok";
statusText = row.entry.statusLabel === "Aktiviert" ? "Aktiv" : row.entry.statusLabel;
}
else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; }
else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; }
else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; }
@@ -2706,8 +2744,17 @@ export function App(): ReactElement {
const traffic = row.dailyLimitBytes > 0
? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
: "Unbeschränkt";
return (
<div key={row.rowKey} className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${isMember ? " acct2-member" : ""}`}>
return (
<div
key={row.rowKey}
className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${isMember ? " acct2-member" : ""}`}
onDoubleClick={() => openEditAccountDialog(row.entry.kind)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
setAccountContextMenu({ x: event.clientX, y: event.clientY, row });
}}
>
<span className="acct2-c-check">
<input
type="checkbox"
@@ -2726,27 +2773,30 @@ export function App(): ReactElement {
<span className="acct2-mode">{row.modeLabel}</span>
</span>
<span className="acct2-traffic">{traffic}</span>
<span className="acct2-status">
<span className="acct2-status">
{statusCls === "none"
? <span className="acct2-nostatus" title="Für diesen Anbieter gibt es keine Status-Prüfung"></span>
: <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>}
</span>
<span className="acct2-user" title={usernameTitle}>{username}</span>
<span className="acct2-expiry">{expiry}</span>
<span className="acct2-c-actions">
{row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && (
<button className="btn btn-sm" disabled={actionBusy} onClick={() => { void onAccountRowQuickAction(row.entry); }}>
{getAccountQuickActionMeta(row.entry.kind)?.label}
</button>
)}
<button className="btn btn-sm" disabled={actionBusy} title="Account bearbeiten" onClick={() => openEditAccountDialog(row.entry.kind)}>Bearbeiten</button>
<button className="btn btn-sm danger" disabled={actionBusy} title="Account entfernen" onClick={() => {
if (row.toggleKind === "mega" && row.megaLogin) { void onRemoveMegaAccount(row.megaLogin); }
else if (row.toggleKind === "dl" && row.dlKey) { void onRemoveDebridLinkKey(row.dlKey); }
else { void onRemoveAccount(row.entry); }
}}>Entfernen</button>
</span>
</div>
</span>
<span className="acct2-user" title={usernameTitle}>{username}</span>
<span className="acct2-expiry">{expiry}</span>
<span className="acct2-credential" title="Zugangsdaten werden niemals im Klartext angezeigt">{row.credentialLabel}</span>
<span className="acct2-c-actions">
<button
className="btn btn-sm acct2-menu-button"
disabled={actionBusy}
title="Account-Aktionen"
aria-label={`${row.hosterLabel} Account-Aktionen`}
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
setAccountContextMenu({ x: rect.right, y: rect.bottom, row });
}}
>
</button>
</span>
</div>
);
};
const availableAccountOptions = useMemo(() => (
@@ -3193,7 +3243,7 @@ export function App(): ReactElement {
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
};
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
await performQuickAction(async () => {
const provider = entry.service as DebridProvider;
const current = settingsDraft.disabledProviders || [];
@@ -3211,12 +3261,55 @@ export function App(): ReactElement {
: `${entry.serviceLabel} aktiviert`,
2200
);
}, (error) => {
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
});
};
const onCheckUpdates = async (): Promise<void> => {
}, (error) => {
showToast(`${entry.serviceLabel} konnte nicht umgeschaltet werden: ${String(error)}`, 3200);
});
};
const toggleAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
void onToggleMegaAccountEnabled(row.megaLogin, row.disabled);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey);
} else {
void onToggleAccountEnabled(row.entry);
}
};
const removeAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
void onRemoveMegaAccount(row.megaLogin);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onRemoveDebridLinkKey(row.dlKey);
} else {
void onRemoveAccount(row.entry);
}
};
const checkAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
const account = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "")
.find((entry) => entry.id === row.accountId);
if (account) {
void runMegaAccountCheck(account.login, account.password);
}
return;
}
if (row.toggleKind === "dl") {
void checkAllAccounts();
return;
}
if (getAccountQuickActionMeta(row.entry.kind)) {
void onAccountRowQuickAction(row.entry);
return;
}
showToast("Für diesen Account ist keine direkte Statusprüfung verfügbar.", 2800);
};
const onCheckUpdates = async (): Promise<void> => {
let updateResult: UpdateCheckResult | null = null;
await performQuickAction(async () => {
setUpdateInstallProgress(null);
@@ -4125,19 +4218,30 @@ export function App(): ReactElement {
setOpenSubmenu(null);
};
useEffect(() => {
if (!contextMenu) { return; }
const close = (): void => setContextMenu(null);
useEffect(() => {
if (!contextMenu) { return; }
const close = (): void => setContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [contextMenu]);
};
}, [contextMenu]);
useEffect(() => {
if (!accountContextMenu) { return; }
const close = (): void => setAccountContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [accountContextMenu]);
useLayoutEffect(() => {
if (!contextMenu || !ctxMenuRef.current) return;
useLayoutEffect(() => {
if (!contextMenu || !ctxMenuRef.current) return;
const el = ctxMenuRef.current;
const rect = el.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
@@ -4146,7 +4250,19 @@ export function App(): ReactElement {
if (rect.right > window.innerWidth) {
el.style.left = `${Math.max(0, contextMenu.x - rect.width)}px`;
}
}, [contextMenu]);
}, [contextMenu]);
useLayoutEffect(() => {
if (!accountContextMenu || !accountContextMenuRef.current) return;
const el = accountContextMenuRef.current;
const rect = el.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
el.style.top = `${Math.max(0, accountContextMenu.y - rect.height)}px`;
}
if (rect.right > window.innerWidth) {
el.style.left = `${Math.max(0, accountContextMenu.x - rect.width)}px`;
}
}, [accountContextMenu]);
useEffect(() => {
if (!colHeaderCtx) return;
@@ -5562,8 +5678,8 @@ export function App(): ReactElement {
<div className="settings-section card account-board">
<div className="account-board-header">
<div>
<h3>Accounts</h3>
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div>
<h3>Accountverwaltung</h3>
<div className="hint">Kompakte Übersicht mit Hoster, Traffic, Status, Benutzername, Ablauf und Zugang. Rechtsklick oder Doppelklick öffnet die Aktionen.</div>
</div>
<div className="account-board-header-actions">
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
@@ -5599,10 +5715,11 @@ export function App(): ReactElement {
<span className="acct2-c-check" />
<span>Hoster</span>
<span>Download-Traffic</span>
<span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span>
<span>Benutzername</span>
<span>Verfallsdatum</span>
<span className="acct2-c-actions">Aktion</span>
<span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span>
<span>Benutzername</span>
<span>Verfallsdatum</span>
<span>Zugang</span>
<span className="acct2-c-actions">Aktion</span>
</div>
{groupedAccountRows.flatMap((group) => {
if (group.rows.length < 2) {
@@ -5625,15 +5742,16 @@ export function App(): ReactElement {
<span className="acct2-mode">{group.rows.length} Accounts</span>
</span>
<span className="acct2-traffic" />
<span className="acct2-status acct2-grp-sum">
{okCount > 0 && <span className="account-validity-badge ok">{okCount} OK</span>}
{problemCount > 0 && <span className="account-validity-badge invalid">{problemCount} Problem</span>}
{offCount > 0 && <span className="account-validity-badge disabled">{offCount} aus</span>}
</span>
<span />
<span />
<span />
</div>
<span className="acct2-status acct2-grp-sum">
{okCount > 0 && <span className="account-validity-badge ok">{okCount} OK</span>}
{problemCount > 0 && <span className="account-validity-badge invalid">{problemCount} Problem</span>}
{offCount > 0 && <span className="account-validity-badge disabled">{offCount} aus</span>}
</span>
<span />
<span />
<span />
<span />
</div>
];
if (!collapsed) {
for (const row of group.rows) elements.push(renderAccountRow(row, true));
@@ -6684,7 +6802,7 @@ export function App(): ReactElement {
<span>{snapshot.etaText}</span>
</footer>
{updateInstallProgress && (
{updateInstallProgress && (
<div className={`update-popup update-popup-${updateInstallProgress.stage}`}>
<div className="update-popup-header">
<span className="update-popup-title">Update</span>
@@ -6699,10 +6817,42 @@ export function App(): ReactElement {
</div>
)}
</div>
)}
{statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div>}
{contextMenu && (() => {
)}
{statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div>}
{accountContextMenu && (
<div
ref={accountContextMenuRef}
className="ctx-menu account-context-menu"
style={{ left: accountContextMenu.x, top: accountContextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
<div className="account-context-heading">
<strong>{accountContextMenu.row.hosterLabel}</strong>
<span>{accountContextMenu.row.modeLabel} · {accountContextMenu.row.username}</span>
</div>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => { const row = accountContextMenu.row; setAccountContextMenu(null); openEditAccountDialog(row.entry.kind); }}>
Bearbeiten
</button>
<button className="ctx-menu-item" onClick={() => checkAccountTableRow(accountContextMenu.row)}>
Account prüfen
</button>
{getAccountQuickActionMeta(accountContextMenu.row.entry.kind) && (
<button className="ctx-menu-item" onClick={() => { const row = accountContextMenu.row; setAccountContextMenu(null); void onAccountRowQuickAction(row.entry); }}>
{getAccountQuickActionMeta(accountContextMenu.row.entry.kind)?.label}
</button>
)}
<button className="ctx-menu-item" onClick={() => toggleAccountTableRow(accountContextMenu.row)}>
{accountContextMenu.row.disabled ? "Account aktivieren" : "Account deaktivieren"}
</button>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item ctx-danger" onClick={() => removeAccountTableRow(accountContextMenu.row)}>
Account entfernen
</button>
</div>
)}
{contextMenu && (() => {
const multi = selectedIds.size > 1;
const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
const selectedItemIds = [...selectedIds].filter((id) => snapshot.session.items[id]);
+19 -12
View File
@@ -3367,13 +3367,13 @@ td {
overflow: hidden;
margin-top: 4px;
}
.acct2-head,
.acct2-row {
display: grid;
grid-template-columns: 32px minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1.3fr) minmax(0, 1.7fr) minmax(0, 1fr) 196px;
align-items: center;
gap: 10px;
padding: 7px 12px;
.acct2-head,
.acct2-row {
display: grid;
grid-template-columns: 32px minmax(0, 1.35fr) minmax(0, 1.25fr) minmax(0, 1.3fr) minmax(0, 1.45fr) minmax(0, 1fr) minmax(0, 0.9fr) 56px;
align-items: center;
gap: 10px;
padding: 7px 12px;
}
.acct2-head > span,
.acct2-row > span {
@@ -3411,8 +3411,9 @@ td {
}
.acct2-c-check { display: flex; justify-content: center; }
.acct2-c-check input { cursor: pointer; }
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
.acct2-hoster { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
.acct2-menu-button { min-width: 38px; padding-inline: 8px; font-size: 18px; line-height: 1; }
.acct2-hoster { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.acct2-hoster strong { font-size: 13px; }
.acct2-mode { font-size: 11px; color: var(--muted); }
.acct2-traffic,
@@ -3421,9 +3422,15 @@ td {
.acct2-traffic { text-align: center; }
.acct2-sortable { cursor: pointer; user-select: none; }
.acct2-sortable:hover { color: var(--text); }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-status .account-validity-badge { margin-top: 0; }
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-credential { color: var(--muted); white-space: nowrap; }
.acct2-status .account-validity-badge { margin-top: 0; }
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
.account-context-menu { min-width: 230px; }
.account-context-heading { display: grid; gap: 3px; padding: 8px 12px; }
.account-context-heading strong { color: var(--text); font-size: 13px; }
.account-context-heading span { color: var(--muted); font-size: 11px; }
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }