Account-Liste: einklappbare Anbieter-Gruppen (z.B. "Mega-Debrid · 4 Accounts")

User-Idee: Anbieter mit mehreren Accounts zusammenfassbar machen, platzsparend.

- Anbieter mit >= 2 Accounts (Mega-Debrid, Debrid-Link) bekommen einen klickbaren
  Gruppen-Kopf "<Anbieter> · N Accounts" mit Chevron zum Ein-/Ausklappen; Einzel-
  account-Anbieter bleiben normale flache Zeilen.
- Gruppen-Kopf zeigt eine Mini-Status-Zusammenfassung als Badges: "X OK" (gruen),
  "Y Problem" (rot, ungueltige Logins), "Z aus" (deaktiviert).
- Standard ausgeklappt (Status bleibt auf einen Blick sichtbar); Klapp-Zustand wird
  pro Anbieter in localStorage gemerkt (rd-account-collapsed-groups-v1).
- Mitglied-Zeilen leicht eingerueckt mit Akzent-Leiste. Zebra-Streifen entfernt
  (mit Gruppen-Koepfen unruhig).
- Zeilen-Rendering in renderAccountRow ausgelagert (fuer flache + Gruppen-Mitglieder
  wiederverwendet); neues groupedAccountRows-useMemo gruppiert accountRows nach Service.

Reine Renderer-Aenderung. 810 Tests gruen, tsc=6, build ok.
This commit is contained in:
Sucukdeluxe 2026-06-15 01:46:23 +02:00
parent 04b8ba1dc4
commit 32e43d1a62
2 changed files with 134 additions and 55 deletions

View File

@ -2616,6 +2616,99 @@ export function App(): ReactElement {
}
return rows;
}, [configuredAccounts, settingsDraft, snapshot.settings]);
const groupedAccountRows = useMemo(() => {
const groups: { service: string; serviceLabel: string; rows: typeof accountRows }[] = [];
const byService = new Map<string, { service: string; serviceLabel: string; rows: typeof accountRows }>();
for (const row of accountRows) {
const key = row.entry.service;
let group = byService.get(key);
if (!group) {
group = { service: key, serviceLabel: row.hosterLabel, rows: [] };
byService.set(key, group);
groups.push(group);
}
group.rows.push(row);
}
return groups;
}, [accountRows]);
const [collapsedAccountGroups, setCollapsedAccountGroups] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem("rd-account-collapsed-groups-v1");
if (raw) return new Set(JSON.parse(raw) as string[]);
} catch { /* ignore */ }
return new Set<string>();
});
const toggleAccountGroup = (service: string): void => {
setCollapsedAccountGroups((prev) => {
const next = new Set(prev);
if (next.has(service)) next.delete(service); else next.add(service);
try { localStorage.setItem("rd-account-collapsed-groups-v1", JSON.stringify([...next])); } catch { /* ignore */ }
return next;
});
};
const renderAccountRow = (row: (typeof accountRows)[number], 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 = "ok";
let statusText = "Konfiguriert";
if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; }
else if (!row.checkable) { statusCls = "ok"; statusText = "Konfiguriert"; }
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"; }
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
const isProblem = statusCls === "invalid";
const username = st && st.email ? st.email : row.username;
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
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" : ""}`}>
<span className="acct2-c-check">
<input
type="checkbox"
checked={!row.disabled}
disabled={actionBusy}
title={row.disabled ? "Account aktivieren" : "Account deaktivieren (bleibt gespeichert)"}
onChange={() => {
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); }
}}
/>
</span>
<span className="acct2-hoster">
<strong>{row.hosterLabel}</strong>
<span className="acct2-mode">{row.modeLabel}</span>
</span>
<span className="acct2-traffic">{traffic}</span>
<span className="acct2-status">
<span className={`account-validity-badge ${statusCls}`}>{statusText}</span>
</span>
<span className="acct2-user" title={username}>{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>
);
};
const availableAccountOptions = useMemo(() => (
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
), [configuredAccountServices]);
@ -5325,64 +5418,41 @@ export function App(): ReactElement {
<span>Verfallsdatum</span>
<span className="acct2-c-actions">Aktion</span>
</div>
{accountRows.map((row) => {
const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
let statusCls = "ok";
let statusText = "Konfiguriert";
if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; }
else if (!row.checkable) { statusCls = "ok"; statusText = "Konfiguriert"; }
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"; }
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
const isProblem = statusCls === "invalid";
const username = st && st.email ? st.email : row.username;
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
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" : ""}`}>
<span className="acct2-c-check">
<input
type="checkbox"
checked={!row.disabled}
disabled={actionBusy}
title={row.disabled ? "Account aktivieren" : "Account deaktivieren (bleibt gespeichert)"}
onChange={() => {
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); }
}}
/>
</span>
{groupedAccountRows.flatMap((group) => {
if (group.rows.length < 2) {
return group.rows.map((row) => renderAccountRow(row, false));
}
const collapsed = collapsedAccountGroups.has(group.service);
let okCount = 0;
let problemCount = 0;
let offCount = 0;
for (const r of group.rows) {
if (r.disabled) { offCount += 1; continue; }
const s = r.accountId ? (snapshot.settings?.debridAccountStatuses?.[r.accountId] ?? null) : null;
if (r.checkable && s && !s.valid) { problemCount += 1; } else { okCount += 1; }
}
const elements: ReactElement[] = [
<div key={`grp-${group.service}`} className="acct2-row acct2-group-head" title={collapsed ? "Ausklappen" : "Einklappen"} onClick={() => toggleAccountGroup(group.service)}>
<span className="acct2-c-check"><span className={`acct2-chevron${collapsed ? "" : " open"}`}>{"▶"}</span></span>
<span className="acct2-hoster">
<strong>{row.hosterLabel}</strong>
<span className="acct2-mode">{row.modeLabel}</span>
<strong>{group.serviceLabel}</strong>
<span className="acct2-mode">{group.rows.length} Accounts</span>
</span>
<span className="acct2-traffic">{traffic}</span>
<span className="acct2-status">
<span className={`account-validity-badge ${statusCls}`}>{statusText}</span>
</span>
<span className="acct2-user" title={username}>{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 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>
);
];
if (!collapsed) {
for (const row of group.rows) elements.push(renderAccountRow(row, true));
}
return elements;
})}
</div>
)}

View File

@ -3291,9 +3291,18 @@ td {
font-size: 13px;
}
.acct2-row:last-child { border-bottom: 0; }
.acct2-row:nth-child(even) { background: color-mix(in srgb, var(--card) 28%, transparent); }
.acct2-row.acct2-problem { background: color-mix(in srgb, var(--danger) 15%, transparent); }
.acct2-row.acct2-disabled { opacity: 0.55; }
.acct2-group-head { cursor: pointer; background: color-mix(in srgb, var(--card) 50%, transparent); }
.acct2-group-head:hover { background: color-mix(in srgb, var(--card) 68%, transparent); }
.acct2-group-head .acct2-hoster strong { font-size: 13px; }
.acct2-chevron { display: inline-block; color: var(--muted); font-size: 9px; transition: transform 0.12s ease; }
.acct2-chevron.open { transform: rotate(90deg); }
.acct2-grp-sum { display: flex; gap: 6px; flex-wrap: wrap; }
.acct2-row.acct2-member .acct2-hoster {
padding-left: 12px;
border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
}
.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; }