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:
parent
04b8ba1dc4
commit
32e43d1a62
@ -2616,6 +2616,99 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
}, [configuredAccounts, settingsDraft, snapshot.settings]);
|
}, [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(() => (
|
const availableAccountOptions = useMemo(() => (
|
||||||
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
|
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
|
||||||
), [configuredAccountServices]);
|
), [configuredAccountServices]);
|
||||||
@ -5325,64 +5418,41 @@ export function App(): ReactElement {
|
|||||||
<span>Verfallsdatum</span>
|
<span>Verfallsdatum</span>
|
||||||
<span className="acct2-c-actions">Aktion</span>
|
<span className="acct2-c-actions">Aktion</span>
|
||||||
</div>
|
</div>
|
||||||
{accountRows.map((row) => {
|
{groupedAccountRows.flatMap((group) => {
|
||||||
const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
|
if (group.rows.length < 2) {
|
||||||
const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
|
return group.rows.map((row) => renderAccountRow(row, false));
|
||||||
let statusCls = "ok";
|
}
|
||||||
let statusText = "Konfiguriert";
|
const collapsed = collapsedAccountGroups.has(group.service);
|
||||||
if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; }
|
let okCount = 0;
|
||||||
else if (!row.checkable) { statusCls = "ok"; statusText = "Konfiguriert"; }
|
let problemCount = 0;
|
||||||
else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; }
|
let offCount = 0;
|
||||||
else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; }
|
for (const r of group.rows) {
|
||||||
else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; }
|
if (r.disabled) { offCount += 1; continue; }
|
||||||
else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
|
const s = r.accountId ? (snapshot.settings?.debridAccountStatuses?.[r.accountId] ?? null) : null;
|
||||||
else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
|
if (r.checkable && s && !s.valid) { problemCount += 1; } else { okCount += 1; }
|
||||||
const isProblem = statusCls === "invalid";
|
}
|
||||||
const username = st && st.email ? st.email : row.username;
|
const elements: ReactElement[] = [
|
||||||
const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
|
<div key={`grp-${group.service}`} className="acct2-row acct2-group-head" title={collapsed ? "Ausklappen" : "Einklappen"} onClick={() => toggleAccountGroup(group.service)}>
|
||||||
const traffic = row.dailyLimitBytes > 0
|
<span className="acct2-c-check"><span className={`acct2-chevron${collapsed ? "" : " open"}`}>{"▶"}</span></span>
|
||||||
? `${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>
|
|
||||||
<span className="acct2-hoster">
|
<span className="acct2-hoster">
|
||||||
<strong>{row.hosterLabel}</strong>
|
<strong>{group.serviceLabel}</strong>
|
||||||
<span className="acct2-mode">{row.modeLabel}</span>
|
<span className="acct2-mode">{group.rows.length} Accounts</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="acct2-traffic">{traffic}</span>
|
<span className="acct2-traffic" />
|
||||||
<span className="acct2-status">
|
<span className="acct2-status acct2-grp-sum">
|
||||||
<span className={`account-validity-badge ${statusCls}`}>{statusText}</span>
|
{okCount > 0 && <span className="account-validity-badge ok">{okCount} OK</span>}
|
||||||
</span>
|
{problemCount > 0 && <span className="account-validity-badge invalid">{problemCount} Problem</span>}
|
||||||
<span className="acct2-user" title={username}>{username}</span>
|
{offCount > 0 && <span className="account-validity-badge disabled">{offCount} aus</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>
|
</span>
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
</div>
|
</div>
|
||||||
);
|
];
|
||||||
|
if (!collapsed) {
|
||||||
|
for (const row of group.rows) elements.push(renderAccountRow(row, true));
|
||||||
|
}
|
||||||
|
return elements;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -3291,9 +3291,18 @@ td {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.acct2-row:last-child { border-bottom: 0; }
|
.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-problem { background: color-mix(in srgb, var(--danger) 15%, transparent); }
|
||||||
.acct2-row.acct2-disabled { opacity: 0.55; }
|
.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 { display: flex; justify-content: center; }
|
||||||
.acct2-c-check input { cursor: pointer; }
|
.acct2-c-check input { cursor: pointer; }
|
||||||
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user