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",
+193 -43
View File
@@ -167,6 +167,31 @@ interface ConfiguredAccountEntry {
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 => {
if (value.freeBytes === null || value.totalBytes === null) {
@@ -446,6 +471,28 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
}
}
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;
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
@@ -1781,6 +1828,8 @@ export function App(): ReactElement {
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("");
@@ -2552,24 +2601,7 @@ 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 rows: AccountTableRow[] = [];
for (const entry of configuredAccounts) {
if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") {
const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "");
@@ -2582,6 +2614,7 @@ export function App(): ReactElement {
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: acc.maskedLogin,
credentialLabel: "••••••",
accountId: acc.id,
checkable: true,
disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id),
@@ -2601,6 +2634,7 @@ export function App(): ReactElement {
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: key.masked,
credentialLabel: "API-Key",
accountId: key.id,
checkable: true,
disabled: key.disabled,
@@ -2619,6 +2653,7 @@ export function App(): ReactElement {
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: entry.summary,
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null,
checkable: false,
disabled: entry.disabled,
@@ -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"; }
@@ -2707,7 +2745,16 @@ export function App(): ReactElement {
? `${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" : ""}`}>
<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"
@@ -2733,18 +2780,21 @@ export function App(): ReactElement {
</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">
{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>
<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>
);
@@ -3216,6 +3266,49 @@ export function App(): ReactElement {
});
};
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 () => {
@@ -4136,6 +4229,17 @@ export function App(): ReactElement {
};
}, [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;
const el = ctxMenuRef.current;
@@ -4148,6 +4252,18 @@ export function App(): ReactElement {
}
}, [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;
const close = (e: MouseEvent): void => {
@@ -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">
@@ -5602,6 +5718,7 @@ export function App(): ReactElement {
<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) => {
@@ -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));
@@ -6702,6 +6820,38 @@ export function App(): ReactElement {
)}
{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]);
+8 -1
View File
@@ -3370,7 +3370,7 @@ td {
.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;
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;
@@ -3412,6 +3412,7 @@ 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-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); }
@@ -3422,9 +3423,15 @@ td {
.acct2-sortable { cursor: pointer; user-select: none; }
.acct2-sortable:hover { color: var(--text); }
.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; }
.rotation-event {