UI Phase 3: Account-Verwaltung als JDownloader-artige Liste (eine Zeile pro Account, Status inline) + Pruefung beim Hinzufuegen

Account-Rework (User-Wunsch nach JDownloader-Vorlage-Screenshot):

- Tabelle zeigt jetzt EINE ZEILE PRO EINZELNEM ACCOUNT statt eine Zeile pro
  Anbieter. Mega-Debrid mit 4 Accounts = 4 Zeilen, Debrid-Link je Key eine Zeile,
  Single-Token-Anbieter je eine Zeile. Neues geflachtes Modell accountRows
  (useMemo) aus configuredAccounts: Mega ueber parseMegaDebridAccounts, DL ueber
  entry.debridLinkKeys, Rest 1:1.
- Spalten wie JDownloader: aktiviert-Checkbox | Hoster (+Modus) | Download-Traffic
  (Rest von Limit "X von Y uebrig" bzw. "Unbeschraenkt") | Status (farbig) |
  Benutzername | Verfallsdatum | Aktion (Bearbeiten/Entfernen).
- STATUS INLINE + farbig (gruen Premium / gelb Free / rot ungueltig / grau nicht
  geprueft / Deaktiviert) direkt in der Zeile, ohne erst "Bearbeiten" zu oeffnen.
  Quelle: settings.debridAccountStatuses[accountId] (Mega/DL werden geprueft),
  Benutzername aus status.email, Verfallsdatum aus premiumUntilMs. Single-Token-
  Anbieter (Real-Debrid, AllDebrid, 1Fichier, ...) werden nicht geprueft -> "Konfiguriert".
- Problemzeilen (Login ungueltig) rot hinterlegt, deaktivierte Zeilen ausgegraut.
- aktiviert-Checkbox schaltet den EINZELNEN Account: Mega via neuem
  onToggleMegaAccountEnabled (megaDebridDisabledAccountIds), DL via bestehendem
  onToggleDebridLinkApiKeyEnabled, Single via onToggleAccountEnabled. Entfernen je
  Account: Mega/DL ueber neue Handler (Zeile aus megaCredentials/debridLinkApiKeys
  raus), Single ueber onRemoveAccount.
- PRUEFUNG BEIM HINZUFUEGEN: onSaveAccountDialog stoesst nach dem Speichern
  checkAllAccounts() an -> Status erscheint sofort in der Liste + Toast meldet
  "X/Y Login gueltig, Z Premium" (Speichern bleibt erlaubt, wie gewuenscht).
- Hoster-Reihenfolge- und Rotations-Verlauf-Panel unveraendert darunter. Alte
  resizable Spalten + "Zugang einzeln"-Toggle entfernt (durch Zeilen ersetzt).
  Web-Login-/AllDebrid-Status-Aktion bleibt als Knopf in der Single-Zeile.
- account-validity-badge.ok von Neon-Verlauf auf flaches Gruen, .disabled ergaenzt.

Reine Renderer-Aenderung. 810 Tests gruen, tsc=6 Baseline, build ok.
Erste Version nach Screenshot — Feinschliff der Optik nach User-Feedback.
This commit is contained in:
Sucukdeluxe 2026-06-15 01:11:56 +02:00
parent 1ea9c42d04
commit d594afe93a
2 changed files with 231 additions and 142 deletions

View File

@ -26,6 +26,9 @@ import {
getDebridLinkApiKeyDailyLimitBytes, getDebridLinkApiKeyDailyLimitBytes,
getDebridLinkApiKeyDailyRemainingBytes, getDebridLinkApiKeyDailyRemainingBytes,
getDebridLinkApiKeyDailyUsageBytes, getDebridLinkApiKeyDailyUsageBytes,
getMegaDebridAccountDailyLimitBytes,
getMegaDebridAccountDailyUsageBytes,
getMegaDebridAccountTotalUsageBytes,
getProviderDailyLimitBytes, getProviderDailyLimitBytes,
getProviderDailyRemainingBytes, getProviderDailyRemainingBytes,
getProviderTotalUsageBytes, getProviderTotalUsageBytes,
@ -2531,6 +2534,88 @@ export function App(): ReactElement {
}, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]); }, [settingsDraft, snapshot.settings, allDebridHostInfo, allDebridHostLoading, hasSavedAllDebridAccount, allDebridSettingsDirty]);
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); 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[] = [];
for (const entry of configuredAccounts) {
if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") {
const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "");
for (const acc of accounts) {
const used = getMegaDebridAccountDailyUsageBytes(snapshot.settings, acc.id);
const limit = getMegaDebridAccountDailyLimitBytes(settingsDraft, acc.id);
rows.push({
rowKey: `mega-${acc.id}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: acc.maskedLogin,
accountId: acc.id,
checkable: true,
disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id),
dailyUsedBytes: used,
dailyLimitBytes: limit,
dailyRemainingBytes: limit > 0 ? Math.max(0, limit - used) : 0,
totalUsedBytes: getMegaDebridAccountTotalUsageBytes(snapshot.settings, acc.id),
toggleKind: "mega",
megaLogin: acc.login
});
}
} else if (entry.kind === "debridlink-api") {
for (const key of entry.debridLinkKeys) {
rows.push({
rowKey: `dl-${key.id}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: key.masked,
accountId: key.id,
checkable: true,
disabled: key.disabled,
dailyUsedBytes: key.dailyUsedBytes,
dailyLimitBytes: key.dailyLimitBytes,
dailyRemainingBytes: key.dailyLimitBytes > 0 ? Math.max(0, key.dailyLimitBytes - key.dailyUsedBytes) : 0,
totalUsedBytes: key.totalUsedBytes,
toggleKind: "dl",
dlKey: key
});
}
} else {
rows.push({
rowKey: `svc-${entry.service}`,
entry,
hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel,
username: entry.summary,
accountId: null,
checkable: false,
disabled: entry.disabled,
dailyUsedBytes: entry.dailyUsedBytes,
dailyLimitBytes: entry.dailyLimitBytes,
dailyRemainingBytes: entry.dailyLimitBytes > 0 ? Math.max(0, entry.dailyRemainingBytes ?? 0) : 0,
totalUsedBytes: entry.totalUsedBytes,
toggleKind: "single"
});
}
}
return rows;
}, [configuredAccounts, settingsDraft, snapshot.settings]);
const availableAccountOptions = useMemo(() => ( const availableAccountOptions = useMemo(() => (
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service)) ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
), [configuredAccountServices]); ), [configuredAccountServices]);
@ -2851,6 +2936,7 @@ export function App(): ReactElement {
} else if (selectedOption) { } else if (selectedOption) {
showToast(`${selectedOption.title} gespeichert`, 2200); showToast(`${selectedOption.title} gespeichert`, 2200);
} }
void checkAllAccounts();
}, (error) => { }, (error) => {
showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200); showToast(`Account konnte nicht gespeichert werden: ${String(error)}`, 3200);
}); });
@ -2932,6 +3018,40 @@ export function App(): ReactElement {
}); });
}; };
const onToggleMegaAccountEnabled = async (login: string, currentlyDisabled: boolean): Promise<void> => {
const accId = getMegaDebridAccountId(login.trim());
await performQuickAction(async () => {
const current = settingsDraft.megaDebridDisabledAccountIds || [];
const next = currentlyDisabled ? current.filter((id) => id !== accId) : [...current, accId];
await persistSpecificSettings({ ...settingsDraft, megaDebridDisabledAccountIds: next });
showToast(currentlyDisabled ? "Account aktiviert" : "Account deaktiviert", 2000);
}, (error) => {
showToast(`Umschalten fehlgeschlagen: ${String(error)}`, 3200);
});
};
const onRemoveMegaAccount = async (login: string): Promise<void> => {
const confirmed = await askConfirmPrompt({ title: "Account entfernen", message: `Soll der Mega-Debrid-Account ${maskMegaDebridLogin(login)} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
if (!confirmed) return;
await performQuickAction(async () => {
const remaining = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "")
.filter((a) => a.login.trim().toLowerCase() !== login.trim().toLowerCase());
const nextCreds = serializeMegaDebridAccounts(remaining.map((a) => ({ login: a.login, password: a.password })));
await persistSpecificSettings({ ...settingsDraft, megaCredentials: nextCreds, megaLogin: remaining[0]?.login ?? "", megaPassword: remaining[0]?.password ?? "" });
showToast("Account entfernt", 2000);
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
};
const onRemoveDebridLinkKey = async (key: DebridLinkAccountKeyEntry): Promise<void> => {
const confirmed = await askConfirmPrompt({ title: "Key entfernen", message: `Soll der Debrid-Link-Key ${key.masked} wirklich entfernt werden?`, confirmLabel: "Entfernen", danger: true });
if (!confirmed) return;
await performQuickAction(async () => {
const remaining = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || "").filter((k) => k.id !== key.id);
await persistSpecificSettings({ ...settingsDraft, debridLinkApiKeys: remaining.map((k) => k.token).join("\n") });
showToast("Key entfernt", 2000);
}, (error) => { showToast(`Entfernen fehlgeschlagen: ${String(error)}`, 3200); });
};
const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => { const onToggleAccountEnabled = async (entry: ConfiguredAccountEntry): Promise<void> => {
await performQuickAction(async () => { await performQuickAction(async () => {
const provider = entry.service as DebridProvider; const provider = entry.service as DebridProvider;
@ -5183,160 +5303,84 @@ export function App(): ReactElement {
</div> </div>
<div className="account-board-summary"> <div className="account-board-summary">
<span className="account-inline-stat">{configuredAccounts.length} aktiv</span> <span className="account-inline-stat">{accountRows.length} {accountRows.length === 1 ? "Account" : "Accounts"}</span>
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span> <span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
</div> </div>
<label className="toggle-line account-display-toggle"> {accountRows.length === 0 && (
<input
type="checkbox"
checked={settingsDraft.accountListShowDetailedDebridLinkKeys}
onChange={(e) => setBool("accountListShowDetailedDebridLinkKeys", e.target.checked)}
/>
Debrid-Link-Keys im Feld "Zugang" einzeln untereinander anzeigen
</label>
<div className="account-display-actions">
<button className="btn btn-sm" disabled={actionBusy} onClick={resetAccountColumnWidths}>
Spalten zurücksetzen
</button>
</div>
{configuredAccounts.length === 0 && (
<div className="account-empty-state"> <div className="account-empty-state">
<strong>Noch keine Accounts hinterlegt</strong> <strong>Noch keine Accounts hinterlegt</strong>
<span>Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span> <span>Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span>
</div> </div>
)} )}
{configuredAccounts.length > 0 && ( {accountRows.length > 0 && (
<div className="account-table" style={accountTableStyle}> <div className="acct2-table">
<div className="account-table-head"> <div className="acct2-head">
<div className="account-header-cell"> <span className="acct2-c-check" />
<span>Account</span> <span>Hoster</span>
<button <span>Download-Traffic</span>
className="account-resize-handle"
title="Spalte ziehen"
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("service", event.clientX);
}}
/>
</div>
<div className="account-header-cell">
<span>Typ</span>
<button
className="account-resize-handle"
title="Spalte ziehen"
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("mode", event.clientX);
}}
/>
</div>
<div className="account-header-cell">
<span>Status</span> <span>Status</span>
<button <span>Benutzername</span>
className="account-resize-handle" <span>Verfallsdatum</span>
title="Spalte ziehen" <span className="acct2-c-actions">Aktion</span>
onMouseDown={(event) => {
event.preventDefault();
startAccountColumnResize("status", event.clientX);
}}
/>
</div> </div>
<div className="account-header-cell"> {accountRows.map((row) => {
<span>Info</span> const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
</div> const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
<div className="account-header-cell"> let statusCls = "ok";
<span>Zugang</span> let statusText = "Konfiguriert";
<button if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; }
className="account-resize-handle" else if (!row.checkable) { statusCls = "ok"; statusText = "Konfiguriert"; }
title="Spalte ziehen" else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; }
onMouseDown={(event) => { else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; }
event.preventDefault(); else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; }
startAccountColumnResize("secret", event.clientX); else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; }
}} else { statusCls = "ok"; statusText = st.message || "Premium Account"; }
/> const isProblem = statusCls === "invalid";
</div> const username = st && st.email ? st.email : row.username;
<div className="account-header-cell"> const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—";
<span>Aktionen</span> const traffic = row.dailyLimitBytes > 0
</div> ? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
</div> : "Unbeschränkt";
{configuredAccounts.map((entry) => {
const option = findAccountOption(entry.kind);
const quickAction = getAccountQuickActionMeta(entry.kind);
const showStatusButton = entry.service === "alldebrid";
const showQuickActionButton = Boolean(quickAction && !(showStatusButton && quickAction.action === "alldebrid-status"));
const allDebridStateClass = entry.service === "alldebrid" && allDebridHostInfo ? ` account-status-${allDebridHostInfo.state}` : "";
return ( return (
<div key={entry.service} className={`account-row${entry.disabled ? " account-row-disabled" : ""}`}> <div key={row.rowKey} className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}`}>
<div className="account-cell account-service-cell"> <span className="acct2-c-check">
<strong>{entry.serviceLabel}</strong> <input
<span>{option.title}</span> type="checkbox"
</div> checked={!row.disabled}
<div className="account-cell"> disabled={actionBusy}
<span className="account-mode-pill">{entry.modeLabel}</span> title={row.disabled ? "Account aktivieren" : "Account deaktivieren (bleibt gespeichert)"}
</div> onChange={() => {
<div className="account-cell account-status-cell"> if (row.toggleKind === "mega" && row.megaLogin) { void onToggleMegaAccountEnabled(row.megaLogin, row.disabled); }
<span className={`account-status-pill${entry.disabled ? " account-status-disabled" : ""}${allDebridStateClass}`}>{entry.statusLabel}</span> else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); }
{entry.note && <span className="account-note">{entry.note}</span>} else { void onToggleAccountEnabled(row.entry); }
</div> }}
<div className="account-cell account-info-cell"> />
{entry.debridLinkKeys.length > 0 ? ( </span>
<div className="account-usage-stack"> <span className="acct2-hoster">
<button className="btn btn-sm" onClick={() => setKeyStatsPopup(entry.service)}> <strong>{row.hosterLabel}</strong>
Statistik <span className="acct2-mode">{row.modeLabel}</span>
</button> </span>
<span className="account-usage-total">Insgesamt: {humanSize(entry.totalUsedBytes)}</span> <span className="acct2-traffic">{traffic}</span>
</div> <span className="acct2-status">
) : ( <span className={`account-validity-badge ${statusCls}`}>{statusText}</span>
<div className="account-usage-stack"> </span>
<div className={`account-usage-stats${entry.dailyLimitReached ? " warning" : ""}`}> <span className="acct2-user" title={username}>{username}</span>
<span>Heute: {humanSize(entry.dailyUsedBytes)}</span> <span className="acct2-expiry">{expiry}</span>
<span>{entry.dailyLimitBytes > 0 ? `Limit: ${humanSize(entry.dailyLimitBytes)}` : "Kein Tageslimit"}</span> <span className="acct2-c-actions">
{entry.dailyLimitBytes > 0 && ( {row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && (
<span>{entry.dailyLimitReached ? "Fallback aktiv" : `Rest: ${humanSize(entry.dailyRemainingBytes || 0)}`}</span> <button className="btn btn-sm" disabled={actionBusy} onClick={() => { void onAccountRowQuickAction(row.entry); }}>
)} {getAccountQuickActionMeta(row.entry.kind)?.label}
</div>
<span className="account-usage-total">Insgesamt: {humanSize(entry.totalUsedBytes)}</span>
</div>
)}
</div>
<div className="account-cell">
{entry.summaryLines.length > 1 ? (
<div className="account-secret account-secret-multiline">
{entry.summaryLines.map((line) => (
<span key={line}>{line}</span>
))}
</div>
) : (
<span className="account-secret">{entry.summary}</span>
)}
</div>
<div className="account-cell account-row-actions">
{showStatusButton && (
<button className="btn" disabled={actionBusy || allDebridHostLoading || !hasSavedAllDebridAccount} onClick={() => { void performQuickAction(async () => { await runAccountQuickAction("alldebrid-status"); }, (error) => { showToast(`AllDebrid Status fehlgeschlagen: ${String(error)}`, 3200); }); }}>
Status
</button> </button>
)} )}
{showQuickActionButton && quickAction && ( <button className="btn btn-sm" disabled={actionBusy} title="Account bearbeiten" onClick={() => openEditAccountDialog(row.entry.kind)}>Bearbeiten</button>
<button className="btn" disabled={actionBusy} onClick={() => { void onAccountRowQuickAction(entry); }}> <button className="btn btn-sm danger" disabled={actionBusy} title="Account entfernen" onClick={() => {
{quickAction.label} if (row.toggleKind === "mega" && row.megaLogin) { void onRemoveMegaAccount(row.megaLogin); }
</button> else if (row.toggleKind === "dl" && row.dlKey) { void onRemoveDebridLinkKey(row.dlKey); }
)} else { void onRemoveAccount(row.entry); }
<button className="btn" disabled={actionBusy} onClick={() => { void onToggleAccountEnabled(entry); }}> }}>Entfernen</button>
{entry.disabled ? "Aktivieren" : "Deaktivieren"} </span>
</button>
<button className="btn" disabled={actionBusy || entry.dailyUsedBytes <= 0} onClick={() => { void onResetAccountDailyUsage(entry); }}>
Reset Heute
</button>
<button className="btn" disabled={actionBusy} onClick={() => openEditAccountDialog(entry.kind)}>
Bearbeiten
</button>
<button className="btn danger" disabled={actionBusy} onClick={() => { void onRemoveAccount(entry); }}>
Entfernen
</button>
</div>
</div> </div>
); );
})} })}

View File

@ -3255,10 +3255,55 @@ td {
border: 1px solid transparent; border: 1px solid transparent;
white-space: nowrap; white-space: nowrap;
} }
.account-validity-badge.ok { color: #1c1206; background: linear-gradient(90deg, #7bd88f, #4fb96a); border-color: #4fb96a; } .account-validity-badge.ok { color: #10240f; background: #4fb96a; border-color: #3f9d57; }
.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; } .account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; }
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; } .account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); } .account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
.account-validity-badge.disabled { color: var(--muted, #a59c8e); background: transparent; border-color: var(--border); opacity: 0.8; }
.acct2-table {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
margin-top: 4px;
}
.acct2-head,
.acct2-row {
display: grid;
grid-template-columns: 34px minmax(110px, 1.1fr) minmax(150px, 1.5fr) minmax(120px, 1.4fr) minmax(140px, 1.6fr) minmax(92px, 0.9fr) auto;
align-items: center;
gap: 10px;
padding: 7px 12px;
}
.acct2-head {
background: color-mix(in srgb, var(--card) 60%, transparent);
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.acct2-row {
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
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-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-hoster strong { font-size: 13px; }
.acct2-mode { font-size: 11px; color: var(--muted); }
.acct2-traffic,
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-status .account-validity-badge { margin-top: 0; }
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; } .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-empty { color: var(--muted, #a59c8e); font-size: 12px; }