From 41b5d62d14f6c79b006d5f17d0b899db9be7311b Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 9 Aug 2026 15:18:24 +0200 Subject: [PATCH] feat: add compact account management actions --- README.md | 6 + package-lock.json | 4 +- package.json | 2 +- src/renderer/App.tsx | 344 +++++++++++++++++++++++++++++----------- src/renderer/styles.css | 31 ++-- 5 files changed, 275 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 3908f98..f153d7c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 5f62895..007f07a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 923a677..28e0385 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 25709cb..64d9d72 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 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 void }>>([]); - const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); - const [contextMenu, setContextMenu] = useState(null); - const ctxMenuRef = useRef(null); + const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); + const [contextMenu, setContextMenu] = useState(null); + const ctxMenuRef = useRef(null); + const [accountContextMenu, setAccountContextMenu] = useState(null); + const accountContextMenuRef = useRef(null); const [linkPopup, setLinkPopup] = useState(null); const [accountDialog, setAccountDialog] = useState(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 ( -
+ return ( +
openEditAccountDialog(row.entry.kind)} + onContextMenu={(event) => { + event.preventDefault(); + event.stopPropagation(); + setAccountContextMenu({ x: event.clientX, y: event.clientY, row }); + }} + > {row.modeLabel} {traffic} - + {statusCls === "none" ? : {statusText}} - - {username} - {expiry} - - {row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && ( - - )} - - - -
+ + {username} + {expiry} + {row.credentialLabel} + + + +
); }; const availableAccountOptions = useMemo(() => ( @@ -3193,7 +3243,7 @@ export function App(): ReactElement { }, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); }); }; - const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise => { + const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise => { 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 => { + }, (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 => { 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 {
-

Accounts

-
Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.
+

Accountverwaltung

+
Kompakte Übersicht mit Hoster, Traffic, Status, Benutzername, Ablauf und Zugang. Rechtsklick oder Doppelklick öffnet die Aktionen.
{groupedAccountRows.flatMap((group) => { if (group.rows.length < 2) { @@ -5625,15 +5742,16 @@ export function App(): ReactElement { {group.rows.length} Accounts - - {okCount > 0 && {okCount} OK} - {problemCount > 0 && {problemCount} Problem} - {offCount > 0 && {offCount} aus} - - - - -
+ + {okCount > 0 && {okCount} OK} + {problemCount > 0 && {problemCount} Problem} + {offCount > 0 && {offCount} aus} + + + + + +
]; if (!collapsed) { for (const row of group.rows) elements.push(renderAccountRow(row, true)); @@ -6684,7 +6802,7 @@ export function App(): ReactElement { {snapshot.etaText} - {updateInstallProgress && ( + {updateInstallProgress && (
Update @@ -6699,10 +6817,42 @@ export function App(): ReactElement {
)}
- )} - {statusToast &&
{statusToast}
} - {dragOver &&
Links, .dlc oder Export-Dateien hier ablegen
} - {contextMenu && (() => { + )} + {statusToast &&
{statusToast}
} + {dragOver &&
Links, .dlc oder Export-Dateien hier ablegen
} + {accountContextMenu && ( +
event.stopPropagation()} + > +
+ {accountContextMenu.row.hosterLabel} + {accountContextMenu.row.modeLabel} · {accountContextMenu.row.username} +
+
+ + + {getAccountQuickActionMeta(accountContextMenu.row.entry.kind) && ( + + )} + +
+ +
+ )} + {contextMenu && (() => { const multi = selectedIds.size > 1; const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]); const selectedItemIds = [...selectedIds].filter((id) => snapshot.session.items[id]); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 5ea8bba..06f8fe8 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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; }