feat: rebuild account management like JDownloader
Replace the grouped account cards with a viewport-filling 24px table, compact provider dialog, and separate usage rules view. Preserve account order and selections across bulk toggles, filtering, deletion, keyboard interaction, and narrow-window scrolling. Add focused regression coverage and publish the 2.0.9 changelog.
This commit is contained in:
@@ -10,6 +10,13 @@ Desktop downloader for Windows with package-based queue management, multi-provid
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### 2.0.9
|
||||||
|
|
||||||
|
- Rebuild the Accounts view as a viewport-filling, JDownloader-style table with flat 24-pixel rows and bottom-aligned actions.
|
||||||
|
- Separate account usage rules from the overview so rotation and routing controls no longer reduce the visible account list.
|
||||||
|
- Redesign the account dialog with one-line provider rows, an immediate default selection, compact credential fields, and split cancel/save actions.
|
||||||
|
- Add a global account enable/disable control while preserving unrelated provider state.
|
||||||
|
|
||||||
### 2.0.8
|
### 2.0.8
|
||||||
|
|
||||||
- Add a compact account-management table with traffic, status, expiry, username, and credential columns.
|
- Add a compact account-management table with traffic, status, expiry, username, and credential columns.
|
||||||
@@ -85,7 +92,6 @@ Desktop downloader for Windows with package-based queue management, multi-provid
|
|||||||
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
- AllDebrid browser-login flow and in-app Rapidgator host status display.
|
||||||
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
- Debrid-Link multi-key management with optional detailed line-by-line key display.
|
||||||
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
- Debrid-Link API-key statistics popup with per-key Rapidgator traffic quota, link quota, reset, activate/deactivate, and click-to-copy masked keys.
|
||||||
- Reset button for stored account column widths in the Accounts table.
|
|
||||||
|
|
||||||
### Download engine
|
### Download engine
|
||||||
|
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.8",
|
"version": "2.0.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.8",
|
"version": "2.0.9",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.8",
|
"version": "2.0.9",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
+227
-357
@@ -1,4 +1,4 @@
|
|||||||
import { CSSProperties, DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
||||||
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
|
||||||
import type {
|
import type {
|
||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
} from "../shared/provider-daily-limits";
|
} from "../shared/provider-daily-limits";
|
||||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||||
import { pruneSelection } from "./selection";
|
import { pruneSelection } from "./selection";
|
||||||
import { matchesAccountModeFilter } from "./account-ui";
|
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, isAccountRowSelectionKey, matchesAccountModeFilter, pruneAccountRowSelection, resolveVisibleAccountKind } from "./account-ui";
|
||||||
import type { AccountModeFilter } from "./account-ui";
|
import type { AccountModeFilter } from "./account-ui";
|
||||||
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
|
||||||
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
import type { DownloadSpeedHistoryState } from "./download-speed-state";
|
||||||
@@ -103,8 +103,6 @@ type AccountKind =
|
|||||||
| "linksnappy-login";
|
| "linksnappy-login";
|
||||||
|
|
||||||
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
|
type AccountQuickAction = "realdebrid-login" | "bestdebrid-cookies" | "alldebrid-login" | "alldebrid-status";
|
||||||
type AccountColumnKey = "service" | "mode" | "status" | "secret";
|
|
||||||
|
|
||||||
interface AccountOption {
|
interface AccountOption {
|
||||||
kind: AccountKind;
|
kind: AccountKind;
|
||||||
service: AccountService;
|
service: AccountService;
|
||||||
@@ -121,9 +119,10 @@ interface MegaDialogAccount {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountDialogState {
|
export interface AccountDialogState {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
kind: AccountKind | null;
|
kind: AccountKind | null;
|
||||||
|
service: AccountService | null;
|
||||||
token: string;
|
token: string;
|
||||||
login: string;
|
login: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -370,41 +369,6 @@ const ACCOUNT_OPTIONS: AccountOption[] = [
|
|||||||
|
|
||||||
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
const ACCOUNT_SERVICES: AccountService[] = ["realdebrid", "megadebrid-api", "megadebrid-web", "bestdebrid", "alldebrid", "ddownload", "onefichier", "debridlink", "linksnappy"];
|
||||||
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
|
const ACCOUNT_LIMIT_BYTES_PER_GIB = 1024 * 1024 * 1024;
|
||||||
const ACCOUNT_COLUMN_STORAGE_KEY = "rd-account-column-widths-v2";
|
|
||||||
const ACCOUNT_COLUMN_DEFAULT_WIDTHS: Record<AccountColumnKey, number> = {
|
|
||||||
service: 240,
|
|
||||||
mode: 96,
|
|
||||||
status: 320,
|
|
||||||
secret: 210
|
|
||||||
};
|
|
||||||
const ACCOUNT_COLUMN_MIN_WIDTHS: Record<AccountColumnKey, number> = {
|
|
||||||
service: 180,
|
|
||||||
mode: 80,
|
|
||||||
status: 180,
|
|
||||||
secret: 140
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadAccountColumnWidths(): Record<AccountColumnKey, number> {
|
|
||||||
if (typeof window === "undefined") {
|
|
||||||
return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
|
||||||
if (!raw) {
|
|
||||||
return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS };
|
|
||||||
}
|
|
||||||
const parsed = JSON.parse(raw) as Partial<Record<AccountColumnKey, unknown>>;
|
|
||||||
return {
|
|
||||||
service: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.service, Number(parsed.service) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.service),
|
|
||||||
mode: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.mode, Number(parsed.mode) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.mode),
|
|
||||||
status: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.status, Number(parsed.status) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.status),
|
|
||||||
secret: Math.max(ACCOUNT_COLUMN_MIN_WIDTHS.secret, Number(parsed.secret) || ACCOUNT_COLUMN_DEFAULT_WIDTHS.secret)
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { ...ACCOUNT_COLUMN_DEFAULT_WIDTHS };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findAccountOption(kind: AccountKind): AccountOption {
|
function findAccountOption(kind: AccountKind): AccountOption {
|
||||||
const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind);
|
const option = ACCOUNT_OPTIONS.find((entry) => entry.kind === kind);
|
||||||
if (!option) {
|
if (!option) {
|
||||||
@@ -498,7 +462,7 @@ function hasMegaDebridCredentials(settings: AppSettings): boolean {
|
|||||||
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
|
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] {
|
function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvider[] {
|
||||||
const list: DebridProvider[] = [];
|
const list: DebridProvider[] = [];
|
||||||
if (settings.token.trim() || settings.realDebridUseWebLogin) {
|
if (settings.token.trim() || settings.realDebridUseWebLogin) {
|
||||||
list.push("realdebrid");
|
list.push("realdebrid");
|
||||||
@@ -521,25 +485,20 @@ function getConfiguredProvidersFromSettings(settings: AppSettings): DebridProvid
|
|||||||
if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) {
|
if ((settings.linkSnappyLogin || "").trim() && (settings.linkSnappyPassword || "").trim()) {
|
||||||
list.push("linksnappy");
|
list.push("linksnappy");
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] {
|
||||||
|
const disabled = new Set(settings.disabledProviders || []);
|
||||||
|
return getConfiguredProvidersFromSettings(settings).filter((provider) => !disabled.has(provider));
|
||||||
|
}
|
||||||
|
|
||||||
function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[] {
|
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
||||||
const disabled = new Set(settings.disabledProviders || []);
|
|
||||||
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
||||||
}
|
const configured = getConfiguredProvidersFromSettings(settings).filter((provider) => !DIRECT_HOSTERS.has(provider));
|
||||||
|
return buildConfiguredProviderOrder(settings.providerOrder || [], configured);
|
||||||
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
}
|
||||||
|
|
||||||
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
|
||||||
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
|
|
||||||
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
|
|
||||||
const inOrder = new Set(ordered);
|
|
||||||
for (const p of active) {
|
|
||||||
if (!inOrder.has(p)) ordered.push(p);
|
|
||||||
}
|
|
||||||
return ordered;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProviderSelectionForSettings(
|
function normalizeProviderSelectionForSettings(
|
||||||
settings: AppSettings
|
settings: AppSettings
|
||||||
@@ -643,12 +602,13 @@ function summarizeAccountLines(kind: AccountKind, settings: AppSettings): string
|
|||||||
return [summarizeAccount(kind, settings)];
|
return [summarizeAccount(kind, settings)];
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
|
function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | null, settings: AppSettings): AccountDialogState {
|
||||||
const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] };
|
const baseMega: Pick<AccountDialogState, "megaAccounts" | "megaNewLogin" | "megaNewPassword" | "megaDisabledIds"> = { megaAccounts: [], megaNewLogin: "", megaNewPassword: "", megaDisabledIds: [] };
|
||||||
if (!kind) {
|
if (!kind) {
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
kind: null,
|
kind: null,
|
||||||
|
service: null,
|
||||||
token: "",
|
token: "",
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -656,14 +616,15 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
keyDailyLimitGbById: {},
|
keyDailyLimitGbById: {},
|
||||||
...baseMega
|
...baseMega
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const provider = getAccountServiceProvider(findAccountOption(kind).service);
|
const service = findAccountOption(kind).service;
|
||||||
|
const provider = getAccountServiceProvider(service);
|
||||||
const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider));
|
const dailyLimitGb = formatAccountDailyLimitInput(getProviderDailyLimitBytes(settings, provider));
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case "realdebrid-api":
|
case "realdebrid-api":
|
||||||
return { mode, kind, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: settings.token, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "realdebrid-web":
|
case "realdebrid-web":
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "megadebrid-api":
|
case "megadebrid-api":
|
||||||
case "megadebrid-web": {
|
case "megadebrid-web": {
|
||||||
let megaToken = (settings.megaCredentials || "").trim();
|
let megaToken = (settings.megaCredentials || "").trim();
|
||||||
@@ -674,24 +635,25 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password }));
|
const megaAccounts = parsed.map((a) => ({ login: a.login, password: a.password }));
|
||||||
const loadedIds = new Set(parsed.map((a) => a.id));
|
const loadedIds = new Set(parsed.map((a) => a.id));
|
||||||
const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id));
|
const megaDisabledIds = (settings.megaDebridDisabledAccountIds || []).filter((id) => loadedIds.has(id));
|
||||||
return { mode, kind, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds };
|
return { mode, kind, service, token: megaToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, megaAccounts, megaNewLogin: "", megaNewPassword: "", megaDisabledIds };
|
||||||
}
|
}
|
||||||
case "bestdebrid-api":
|
case "bestdebrid-api":
|
||||||
return { mode, kind, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: settings.bestToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "bestdebrid-web":
|
case "bestdebrid-web":
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "alldebrid-api":
|
case "alldebrid-api":
|
||||||
return { mode, kind, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: settings.allDebridToken, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "alldebrid-web":
|
case "alldebrid-web":
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "ddownload-login":
|
case "ddownload-login":
|
||||||
return { mode, kind, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "onefichier-api":
|
case "onefichier-api":
|
||||||
return { mode, kind, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "debridlink-api":
|
case "debridlink-api":
|
||||||
return {
|
return {
|
||||||
mode,
|
mode,
|
||||||
kind,
|
kind,
|
||||||
|
service,
|
||||||
token: settings.debridLinkApiKeys || "",
|
token: settings.debridLinkApiKeys || "",
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -700,9 +662,9 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
...baseMega
|
...baseMega
|
||||||
};
|
};
|
||||||
case "linksnappy-login":
|
case "linksnappy-login":
|
||||||
return { mode, kind, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: "", login: settings.linkSnappyLogin || "", password: settings.linkSnappyPassword || "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
default:
|
default:
|
||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, service, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1757,7 +1719,6 @@ export function App(): ReactElement {
|
|||||||
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
const [settingsDraft, setSettingsDraft] = useState<AppSettings>(emptySnapshot().settings);
|
||||||
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps));
|
||||||
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState<Record<string, string>>({});
|
||||||
const [accountColumnWidths, setAccountColumnWidths] = useState<Record<AccountColumnKey, number>>(loadAccountColumnWidths);
|
|
||||||
const [settingsDirty, setSettingsDirty] = useState(false);
|
const [settingsDirty, setSettingsDirty] = useState(false);
|
||||||
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
||||||
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
const [scheduleTimeInput, setScheduleTimeInput] = useState("");
|
||||||
@@ -1808,9 +1769,11 @@ export function App(): ReactElement {
|
|||||||
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
const [supportTraceEnabled, setSupportTraceEnabled] = useState(false);
|
||||||
const dragOverRef = useRef(false);
|
const dragOverRef = useRef(false);
|
||||||
const dragDepthRef = useRef(0);
|
const dragDepthRef = useRef(0);
|
||||||
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
||||||
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSubTab>("allgemein");
|
const [settingsSubTab, setSettingsSubTab] = useState<SettingsSubTab>("allgemein");
|
||||||
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview");
|
||||||
|
const [selectedAccountRowKey, setSelectedAccountRowKey] = useState<string | null>(null);
|
||||||
|
const [openSubmenu, setOpenSubmenu] = useState<string | null>(null);
|
||||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||||
@@ -1856,37 +1819,6 @@ export function App(): ReactElement {
|
|||||||
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
|
const [allDebridHostLoading, setAllDebridHostLoading] = useState(false);
|
||||||
const allDebridHostRequestRef = useRef(0);
|
const allDebridHostRequestRef = useRef(0);
|
||||||
const debridLinkHostLimitsRequestRef = useRef(0);
|
const debridLinkHostLimitsRequestRef = useRef(0);
|
||||||
const accountColumnResizeRef = useRef<{ key: AccountColumnKey; startX: number; startWidth: number } | null>(null);
|
|
||||||
const onAccountColumnResizeMove = useCallback((event: MouseEvent): void => {
|
|
||||||
const active = accountColumnResizeRef.current;
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const nextWidth = Math.max(
|
|
||||||
ACCOUNT_COLUMN_MIN_WIDTHS[active.key],
|
|
||||||
Math.round(active.startWidth + (event.clientX - active.startX))
|
|
||||||
);
|
|
||||||
setAccountColumnWidths((prev) => (
|
|
||||||
prev[active.key] === nextWidth ? prev : { ...prev, [active.key]: nextWidth }
|
|
||||||
));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const stopAccountColumnResize = useCallback((): void => {
|
|
||||||
accountColumnResizeRef.current = null;
|
|
||||||
window.removeEventListener("mousemove", onAccountColumnResizeMove);
|
|
||||||
window.removeEventListener("mouseup", stopAccountColumnResize);
|
|
||||||
}, [onAccountColumnResizeMove]);
|
|
||||||
|
|
||||||
const startAccountColumnResize = useCallback((key: AccountColumnKey, clientX: number): void => {
|
|
||||||
accountColumnResizeRef.current = {
|
|
||||||
key,
|
|
||||||
startX: clientX,
|
|
||||||
startWidth: accountColumnWidths[key]
|
|
||||||
};
|
|
||||||
window.addEventListener("mousemove", onAccountColumnResizeMove);
|
|
||||||
window.addEventListener("mouseup", stopAccountColumnResize);
|
|
||||||
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "history") return;
|
if (tab !== "history") return;
|
||||||
const loadHistory = async (): Promise<void> => {
|
const loadHistory = async (): Promise<void> => {
|
||||||
@@ -1904,22 +1836,6 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]);
|
useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}, [accountColumnWidths]);
|
|
||||||
|
|
||||||
const resetAccountColumnWidths = useCallback((): void => {
|
|
||||||
setAccountColumnWidths({ ...ACCOUNT_COLUMN_DEFAULT_WIDTHS });
|
|
||||||
try {
|
|
||||||
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
showToast("Accounts-Spalten zurückgesetzt", 1800);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const columnOrderKey = useMemo(
|
const columnOrderKey = useMemo(
|
||||||
() => (snapshot.settings.columnOrder || []).join("|"),
|
() => (snapshot.settings.columnOrder || []).join("|"),
|
||||||
[snapshot.settings.columnOrder]
|
[snapshot.settings.columnOrder]
|
||||||
@@ -2217,12 +2133,11 @@ export function App(): ReactElement {
|
|||||||
const request = confirmQueueRef.current.shift();
|
const request = confirmQueueRef.current.shift();
|
||||||
request?.resolve(false);
|
request?.resolve(false);
|
||||||
}
|
}
|
||||||
stopAccountColumnResize();
|
if (unsubscribe) { unsubscribe(); }
|
||||||
if (unsubscribe) { unsubscribe(); }
|
|
||||||
if (unsubClipboard) { unsubClipboard(); }
|
if (unsubClipboard) { unsubClipboard(); }
|
||||||
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
if (unsubUpdateInstallProgress) { unsubUpdateInstallProgress(); }
|
||||||
};
|
};
|
||||||
}, [clearImportQueueFocusListener, stopAccountColumnResize]);
|
}, [clearImportQueueFocusListener]);
|
||||||
|
|
||||||
const downloadsTabActive = tab === "downloads";
|
const downloadsTabActive = tab === "downloads";
|
||||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||||
@@ -2671,58 +2586,29 @@ export function App(): ReactElement {
|
|||||||
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none");
|
||||||
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none"));
|
||||||
|
|
||||||
const groupedAccountRows = useMemo(() => {
|
const sortedAccountRows = useMemo(() => {
|
||||||
const groups: { service: string; serviceLabel: string; rows: typeof accountRows }[] = [];
|
if (accountStatusSort === "none") return accountRows;
|
||||||
const byService = new Map<string, { service: string; serviceLabel: string; rows: typeof accountRows }>();
|
const statuses = snapshot.settings?.debridAccountStatuses || {};
|
||||||
for (const row of accountRows) {
|
const now = Date.now();
|
||||||
const key = row.entry.service;
|
const remainingMs = (row: (typeof accountRows)[number]): number => {
|
||||||
let group = byService.get(key);
|
const status = row.accountId ? statuses[row.accountId] : null;
|
||||||
if (!group) {
|
return status && status.premiumUntilMs && status.premiumUntilMs > now ? status.premiumUntilMs - now : -1;
|
||||||
group = { service: key, serviceLabel: row.hosterLabel, rows: [] };
|
};
|
||||||
byService.set(key, group);
|
return [...accountRows].sort((a, b) => {
|
||||||
groups.push(group);
|
const remainingA = remainingMs(a);
|
||||||
}
|
const remainingB = remainingMs(b);
|
||||||
group.rows.push(row);
|
if (remainingA > 0 && remainingB > 0) return accountStatusSort === "desc" ? remainingB - remainingA : remainingA - remainingB;
|
||||||
}
|
if (remainingA > 0) return -1;
|
||||||
if (accountStatusSort !== "none") {
|
if (remainingB > 0) return 1;
|
||||||
const statuses = snapshot.settings?.debridAccountStatuses || {};
|
return 0;
|
||||||
const now = Date.now();
|
});
|
||||||
const remainingMs = (row: (typeof accountRows)[number]): number => {
|
}, [accountRows, accountStatusSort, snapshot.settings]);
|
||||||
const st = row.accountId ? statuses[row.accountId] : null;
|
|
||||||
return st && st.premiumUntilMs && st.premiumUntilMs > now ? st.premiumUntilMs - now : -1;
|
useEffect(() => {
|
||||||
};
|
setSelectedAccountRowKey((current) => pruneAccountRowSelection(current, accountRows.map((row) => row.rowKey)));
|
||||||
for (const group of groups) {
|
}, [accountRows]);
|
||||||
group.rows = [...group.rows].sort((a, b) => {
|
|
||||||
const ra = remainingMs(a);
|
const renderAccountRow = (row: AccountTableRow): ReactElement => {
|
||||||
const rb = remainingMs(b);
|
|
||||||
if (ra > 0 && rb > 0) return accountStatusSort === "desc" ? rb - ra : ra - rb;
|
|
||||||
if (ra > 0) return -1;
|
|
||||||
if (rb > 0) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups;
|
|
||||||
}, [accountRows, accountStatusSort, snapshot.settings]);
|
|
||||||
|
|
||||||
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: AccountTableRow, isMember: boolean): ReactElement => {
|
|
||||||
const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
|
const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
|
||||||
const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
|
const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
|
||||||
let statusCls = "none";
|
let statusCls = "none";
|
||||||
@@ -2747,7 +2633,17 @@ export function App(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.rowKey}
|
key={row.rowKey}
|
||||||
className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${isMember ? " acct2-member" : ""}`}
|
className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${selectedAccountRowKey === row.rowKey ? " selected" : ""}`}
|
||||||
|
role="row"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-selected={selectedAccountRowKey === row.rowKey}
|
||||||
|
onClick={() => setSelectedAccountRowKey(row.rowKey)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (isAccountRowSelectionKey(event.key, event.target === event.currentTarget)) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedAccountRowKey(row.rowKey);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onDoubleClick={() => openEditAccountDialog(row.entry.kind)}
|
onDoubleClick={() => openEditAccountDialog(row.entry.kind)}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -2768,9 +2664,10 @@ export function App(): ReactElement {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span className="acct2-hoster">
|
<span className="acct2-hoster" title={`${row.hosterLabel} · ${row.modeLabel}`}>
|
||||||
<strong>{row.hosterLabel}</strong>
|
<span className={`acct2-service-icon service-${row.entry.service}`}>{row.hosterLabel.slice(0, 1).toUpperCase()}</span>
|
||||||
<span className="acct2-mode">{row.modeLabel}</span>
|
<strong>{row.hosterLabel}</strong>
|
||||||
|
<span className="acct2-mode">{row.modeLabel}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="acct2-traffic">{traffic}</span>
|
<span className="acct2-traffic">{traffic}</span>
|
||||||
<span className="acct2-status">
|
<span className="acct2-status">
|
||||||
@@ -2803,18 +2700,17 @@ export function App(): ReactElement {
|
|||||||
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
|
ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service))
|
||||||
), [configuredAccountServices]);
|
), [configuredAccountServices]);
|
||||||
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
|
const accountDialogOption = accountDialog?.kind ? findAccountOption(accountDialog.kind) : null;
|
||||||
const accountDialogSelectableOptions = useMemo(() => {
|
const accountDialogSelectableOptions = useMemo(() => {
|
||||||
if (!accountDialog) {
|
if (!accountDialog) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (accountDialog.mode === "edit") {
|
return getAccountDialogSelectableOptions(
|
||||||
if (!accountDialogOption) {
|
ACCOUNT_OPTIONS,
|
||||||
return [];
|
availableAccountOptions,
|
||||||
}
|
accountDialog.mode,
|
||||||
return ACCOUNT_OPTIONS.filter((option) => option.service === accountDialogOption.service);
|
accountDialog.service
|
||||||
}
|
);
|
||||||
return availableAccountOptions;
|
}, [accountDialog, availableAccountOptions]);
|
||||||
}, [accountDialog, accountDialogOption, availableAccountOptions]);
|
|
||||||
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
|
const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase();
|
||||||
const filteredAccountDialogOptions = useMemo(() => (
|
const filteredAccountDialogOptions = useMemo(() => (
|
||||||
accountDialogSelectableOptions.filter((option) => {
|
accountDialogSelectableOptions.filter((option) => {
|
||||||
@@ -2834,13 +2730,6 @@ export function App(): ReactElement {
|
|||||||
return haystack.includes(accountDialogSearchQuery);
|
return haystack.includes(accountDialogSearchQuery);
|
||||||
})
|
})
|
||||||
), [accountDialogModeFilter, accountDialogSearchQuery, accountDialogSelectableOptions]);
|
), [accountDialogModeFilter, accountDialogSearchQuery, accountDialogSelectableOptions]);
|
||||||
const accountTableStyle = useMemo(() => ({
|
|
||||||
"--account-col-service": `${accountColumnWidths.service}px`,
|
|
||||||
"--account-col-mode": `${accountColumnWidths.mode}px`,
|
|
||||||
"--account-col-status": `${accountColumnWidths.status}px`,
|
|
||||||
"--account-col-secret": `${accountColumnWidths.secret}px`
|
|
||||||
} as CSSProperties), [accountColumnWidths]);
|
|
||||||
|
|
||||||
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
|
const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise<void> => {
|
||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -3055,7 +2944,7 @@ export function App(): ReactElement {
|
|||||||
const openCreateAccountDialog = (): void => {
|
const openCreateAccountDialog = (): void => {
|
||||||
setAccountDialogSearch("");
|
setAccountDialogSearch("");
|
||||||
setAccountDialogModeFilter("all");
|
setAccountDialogModeFilter("all");
|
||||||
setAccountDialog(createAccountDialogState("create", null, settingsDraft));
|
setAccountDialog(createAccountDialogState("create", availableAccountOptions[0]?.kind ?? null, settingsDraft));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditAccountDialog = (kind: AccountKind): void => {
|
const openEditAccountDialog = (kind: AccountKind): void => {
|
||||||
@@ -3064,8 +2953,8 @@ export function App(): ReactElement {
|
|||||||
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
|
setAccountDialog(createAccountDialogState("edit", kind, settingsDraft));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateAccountDialogKind = (kind: AccountKind): void => {
|
const updateAccountDialogKind = useCallback((kind: AccountKind): void => {
|
||||||
setAccountDialog((prev) => {
|
setAccountDialog((prev) => {
|
||||||
const next = createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft);
|
const next = createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft);
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
return next;
|
return next;
|
||||||
@@ -3082,10 +2971,28 @@ export function App(): ReactElement {
|
|||||||
next.megaNewLogin = prev.megaNewLogin;
|
next.megaNewLogin = prev.megaNewLogin;
|
||||||
next.megaNewPassword = prev.megaNewPassword;
|
next.megaNewPassword = prev.megaNewPassword;
|
||||||
}
|
}
|
||||||
next.dailyLimitGb = prev.dailyLimitGb;
|
next.dailyLimitGb = prev.dailyLimitGb;
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
}, [settingsDraft]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accountDialog) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visibleKind = resolveVisibleAccountKind(
|
||||||
|
accountDialog.kind,
|
||||||
|
filteredAccountDialogOptions.map((option) => option.kind)
|
||||||
|
);
|
||||||
|
if (visibleKind === accountDialog.kind) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visibleKind) {
|
||||||
|
updateAccountDialogKind(visibleKind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAccountDialog((current) => current ? { ...current, kind: null } : current);
|
||||||
|
}, [accountDialog?.kind, filteredAccountDialogOptions, updateAccountDialogKind]);
|
||||||
|
|
||||||
const closeAccountDialog = useCallback((): void => {
|
const closeAccountDialog = useCallback((): void => {
|
||||||
setAccountDialog(null);
|
setAccountDialog(null);
|
||||||
@@ -3277,6 +3184,27 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setAllAccountsEnabled = async (enabled: boolean): Promise<void> => {
|
||||||
|
await performQuickAction(async () => {
|
||||||
|
const configuredProviderIds = [...new Set(configuredAccounts.map((entry) => entry.service as DebridProvider))];
|
||||||
|
const nextEnabledState = buildBulkAccountEnabledState(
|
||||||
|
settingsDraft.disabledProviders || [],
|
||||||
|
configuredProviderIds,
|
||||||
|
accountRows.filter((row) => row.toggleKind === "mega" && row.accountId).map((row) => row.accountId as string),
|
||||||
|
accountRows.filter((row) => row.toggleKind === "dl" && row.accountId).map((row) => row.accountId as string),
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
const nextDraft: AppSettings = {
|
||||||
|
...settingsDraft,
|
||||||
|
...nextEnabledState
|
||||||
|
};
|
||||||
|
await persistSpecificSettings(nextDraft);
|
||||||
|
showToast(enabled ? "Accounts aktiviert" : "Accounts deaktiviert", 2200);
|
||||||
|
}, (error) => {
|
||||||
|
showToast(`Accounts konnten nicht umgeschaltet werden: ${String(error)}`, 3200);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const removeAccountTableRow = (row: AccountTableRow): void => {
|
const removeAccountTableRow = (row: AccountTableRow): void => {
|
||||||
setAccountContextMenu(null);
|
setAccountContextMenu(null);
|
||||||
if (row.toggleKind === "mega" && row.megaLogin) {
|
if (row.toggleKind === "mega" && row.megaLogin) {
|
||||||
@@ -5580,7 +5508,7 @@ export function App(): ReactElement {
|
|||||||
<button key={st.key} className={`settings-sidebar-tab${settingsSubTab === st.key ? " active" : ""}`} onClick={() => setSettingsSubTab(st.key)}>{st.label}</button>
|
<button key={st.key} className={`settings-sidebar-tab${settingsSubTab === st.key ? " active" : ""}`} onClick={() => setSettingsSubTab(st.key)}>{st.label}</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="settings-content" key={settingsSubTab}>
|
<div className={`settings-content${settingsSubTab === "accounts" ? " settings-content-accounts" : ""}`} key={settingsSubTab}>
|
||||||
{settingsSubTab === "allgemein" && (
|
{settingsSubTab === "allgemein" && (
|
||||||
<div className="settings-section card">
|
<div className="settings-section card">
|
||||||
<h3>Allgemein</h3>
|
<h3>Allgemein</h3>
|
||||||
@@ -5672,97 +5600,52 @@ export function App(): ReactElement {
|
|||||||
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnRunFinished} onChange={(e) => setBool("notifyOnRunFinished", e.target.checked)} /> Melden, wenn alles fertig ist</label>
|
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.notifyOnRunFinished} onChange={(e) => setBool("notifyOnRunFinished", e.target.checked)} /> Melden, wenn alles fertig ist</label>
|
||||||
<div className="setting-hint">Sendet eine Meldung, wenn die ganze Warteschlange abgearbeitet ist.</div>
|
<div className="setting-hint">Sendet eine Meldung, wenn die ganze Warteschlange abgearbeitet ist.</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{settingsSubTab === "accounts" && (
|
{settingsSubTab === "accounts" && (
|
||||||
<div className="account-settings-layout">
|
<div className={`account-settings-layout ${accountManagementTab}`}>
|
||||||
<div className="settings-section card account-board">
|
<div className="account-management-heading">
|
||||||
<div className="account-board-header">
|
<div>
|
||||||
<div>
|
<h3>Accountverwaltung</h3>
|
||||||
<h3>Accountverwaltung</h3>
|
<div className="hint">Hier kannst Du Deine Accounts hinzufügen, prüfen und verwalten.</div>
|
||||||
<div className="hint">Kompakte Übersicht mit Hoster, Traffic, Status, Benutzername, Ablauf und Zugang. Rechtsklick oder Doppelklick öffnet die Aktionen.</div>
|
</div>
|
||||||
</div>
|
<label className="account-use-toggle"><input type="checkbox" checked={accountRows.some((row) => !row.disabled)} disabled={actionBusy || accountRows.length === 0} onChange={(event) => { void setAllAccountsEnabled(event.target.checked); }} /> Accounts zum Herunterladen verwenden</label>
|
||||||
<div className="account-board-header-actions">
|
</div>
|
||||||
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
|
|
||||||
{accountCheckBusy ? "Prüfe Accounts…" : "Alle prüfen"}
|
<div className="account-management-tabs" role="tablist" aria-label="Accountverwaltung">
|
||||||
</button>
|
<button className={accountManagementTab === "overview" ? "active" : ""} onClick={() => setAccountManagementTab("overview")} role="tab" aria-selected={accountManagementTab === "overview"}>Übersicht</button>
|
||||||
<button className="btn account-add-button" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>
|
<button className={accountManagementTab === "rules" ? "active" : ""} onClick={() => setAccountManagementTab("rules")} role="tab" aria-selected={accountManagementTab === "rules"}>Verwendungsregeln</button>
|
||||||
Account hinzufügen
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
<div className="account-overview-panel" hidden={accountManagementTab !== "overview"}>
|
||||||
</div>
|
<div className="acct2-table" role="table" aria-label="Accounts">
|
||||||
|
<div className="acct2-head" role="row">
|
||||||
<div className="account-board-summary">
|
<span className="acct2-c-check" role="columnheader" aria-label="Aktiviert" />
|
||||||
<span className="account-inline-stat">{accountRows.length} {accountRows.length === 1 ? "Account" : "Accounts"}</span>
|
<span role="columnheader">Hoster</span>
|
||||||
<span className="account-inline-stat">{availableAccountOptions.length} weitere Typen verfügbar</span>
|
<span role="columnheader">Download-Traffic übrig</span>
|
||||||
</div>
|
<span role="columnheader"><button type="button" className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</button></span>
|
||||||
|
<span role="columnheader">Benutzername</span>
|
||||||
{configuredAccountServices.has("megadebrid-api") && configuredAccountServices.has("megadebrid-web") && (
|
<span role="columnheader">Verfallsdatum</span>
|
||||||
<div className="account-mode-note">
|
<span role="columnheader">Passwort / Zugang</span>
|
||||||
<strong>Mega-Debrid API + Web sind derselbe Account</strong>, nur zwei Zugriffsarten: API (schnell) wird zuerst versucht, Web dient als Fallback. Gleicher Login, kein zweites Konto — beide Zeilen teilen sich dasselbe Tageslimit und denselben Premium-Status.
|
<span className="acct2-c-actions" role="columnheader" aria-label="Aktionen" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="acct2-body" role="rowgroup">
|
||||||
|
{accountRows.length === 0 ? (
|
||||||
{accountRows.length === 0 && (
|
<div className="acct2-empty">Noch keine Accounts vorhanden.</div>
|
||||||
<div className="account-empty-state">
|
) : sortedAccountRows.map((row) => renderAccountRow(row))}
|
||||||
<strong>Noch keine Accounts hinterlegt</strong>
|
</div>
|
||||||
<span>Füge über "Account hinzufügen" den ersten Dienst hinzu. Danach erscheinen hier Status, Zugang und Aktionen als Liste.</span>
|
</div>
|
||||||
</div>
|
<div className="account-table-actions">
|
||||||
)}
|
<button className="btn btn-sm" disabled={actionBusy || availableAccountOptions.length === 0} onClick={openCreateAccountDialog}>+ Hinzufügen</button>
|
||||||
|
<button className="btn btn-sm" disabled={actionBusy || !selectedAccountRowKey} onClick={() => {
|
||||||
{accountRows.length > 0 && (
|
const selectedRow = accountRows.find((row) => row.rowKey === selectedAccountRowKey);
|
||||||
<div className="acct2-table">
|
if (selectedRow) removeAccountTableRow(selectedRow);
|
||||||
<div className="acct2-head">
|
}}>- Entfernen</button>
|
||||||
<span className="acct2-c-check" />
|
<button className="btn btn-sm" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }}>{accountCheckBusy ? "Prüfe…" : "↻ Aktualisieren"}</button>
|
||||||
<span>Hoster</span>
|
<span>{accountRows.length} {accountRows.length === 1 ? "Account" : "Accounts"}</span>
|
||||||
<span>Download-Traffic</span>
|
</div>
|
||||||
<span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span>
|
</div>
|
||||||
<span>Benutzername</span>
|
|
||||||
<span>Verfallsdatum</span>
|
<div className="settings-section card account-rule-section" hidden={accountManagementTab !== "rules"}>
|
||||||
<span>Zugang</span>
|
|
||||||
<span className="acct2-c-actions">Aktion</span>
|
|
||||||
</div>
|
|
||||||
{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>{group.serviceLabel}</strong>
|
|
||||||
<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 />
|
|
||||||
<span />
|
|
||||||
</div>
|
|
||||||
];
|
|
||||||
if (!collapsed) {
|
|
||||||
for (const row of group.rows) elements.push(renderAccountRow(row, true));
|
|
||||||
}
|
|
||||||
return elements;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="settings-section card">
|
|
||||||
<div className="account-board-header">
|
<div className="account-board-header">
|
||||||
<div>
|
<div>
|
||||||
<h3>Rotations-Verlauf</h3>
|
<h3>Rotations-Verlauf</h3>
|
||||||
@@ -5787,7 +5670,7 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-section card">
|
<div className="settings-section card account-rule-section" hidden={accountManagementTab !== "rules"}>
|
||||||
<h3>Hoster-Reihenfolge</h3>
|
<h3>Hoster-Reihenfolge</h3>
|
||||||
<div className="hint">
|
<div className="hint">
|
||||||
Lege fest, in welcher Reihenfolge die Debrid-Accounts für Links genutzt werden.
|
Lege fest, in welcher Reihenfolge die Debrid-Accounts für Links genutzt werden.
|
||||||
@@ -5844,7 +5727,7 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{configuredProviders.length >= 1 && (
|
{configuredProviders.length >= 1 && (
|
||||||
<div className="settings-section card">
|
<div className="settings-section card account-rule-section" hidden={accountManagementTab !== "rules"}>
|
||||||
<h3>Hoster-Zuordnung</h3>
|
<h3>Hoster-Zuordnung</h3>
|
||||||
<div className="hint">Lege fest, welcher Debrid-Provider sich um welchen Filehoster kümmert. Nicht zugeordnete Hoster nutzen die Standard-Reihenfolge oben.</div>
|
<div className="hint">Lege fest, welcher Debrid-Provider sich um welchen Filehoster kümmert. Nicht zugeordnete Hoster nutzen die Standard-Reihenfolge oben.</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -6425,19 +6308,16 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{accountDialog && (
|
{accountDialog && (
|
||||||
<div className="modal-backdrop" onClick={closeAccountDialog}>
|
<div className="modal-backdrop" onClick={closeAccountDialog}>
|
||||||
<div className="modal-card account-modal" onClick={(event) => event.stopPropagation()}>
|
<div className="modal-card account-modal" role="dialog" aria-modal="true" aria-labelledby="account-dialog-title" onClick={(event) => event.stopPropagation()}>
|
||||||
<div className="account-modal-header">
|
<div className="account-modal-header">
|
||||||
<div>
|
<h3 id="account-dialog-title">{accountDialog.mode === "edit" ? "Account bearbeiten" : "Neuen Account hinzufügen"}</h3>
|
||||||
<h3>{accountDialog.mode === "edit" ? "Account bearbeiten" : "Account hinzufügen"}</h3>
|
</div>
|
||||||
<p>Wie in JDownloader: oben Account-Typ auswaehlen, unten Zugangsdaten direkt eintragen.</p>
|
|
||||||
</div>
|
<div className="account-modal-body">
|
||||||
</div>
|
<div className="account-dialog-step account-modal-provider-list">
|
||||||
|
<div className="account-dialog-step-label">1. Wähle einen Hoster aus</div>
|
||||||
<div className="account-modal-body">
|
|
||||||
<div className="account-dialog-step">
|
|
||||||
<div className="account-dialog-step-label">1. Account-Typ auswaehlen</div>
|
|
||||||
<div className="account-picker-toolbar">
|
<div className="account-picker-toolbar">
|
||||||
<select
|
<select
|
||||||
className="account-picker-filter"
|
className="account-picker-filter"
|
||||||
@@ -6451,6 +6331,7 @@ export function App(): ReactElement {
|
|||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
className="account-picker-search"
|
className="account-picker-search"
|
||||||
|
autoFocus
|
||||||
placeholder="Dienst oder Typ suchen"
|
placeholder="Dienst oder Typ suchen"
|
||||||
value={accountDialogSearch}
|
value={accountDialogSearch}
|
||||||
onChange={(event) => setAccountDialogSearch(event.target.value)}
|
onChange={(event) => setAccountDialogSearch(event.target.value)}
|
||||||
@@ -6471,29 +6352,23 @@ export function App(): ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{filteredAccountDialogOptions.map((option) => (
|
{filteredAccountDialogOptions.map((option) => (
|
||||||
<button
|
<button
|
||||||
key={option.kind}
|
key={option.kind}
|
||||||
className={`account-picker-row${accountDialog.kind === option.kind ? " active" : ""}`}
|
className={`account-picker-row${accountDialog.kind === option.kind ? " active" : ""}`}
|
||||||
onClick={() => updateAccountDialogKind(option.kind)}
|
onClick={() => updateAccountDialogKind(option.kind)}
|
||||||
>
|
>
|
||||||
<div>
|
<span className="account-picker-domain"><span className={`account-picker-icon service-${option.service}`}>{option.serviceLabel.slice(0, 1).toUpperCase()}</span><strong>{option.title}</strong></span>
|
||||||
<strong>{option.title}</strong>
|
<span className="account-picker-function">{getAccountPickerFunctionLabel(option)}</span>
|
||||||
<span>{option.serviceLabel}</span>
|
</button>
|
||||||
</div>
|
))}
|
||||||
<div>
|
</div>
|
||||||
<strong>{getAccountPickerFunctionLabel(option)}</strong>
|
</div>
|
||||||
<span>{option.pickerDescription}</span>
|
|
||||||
</div>
|
<div className="account-dialog-step account-modal-credentials">
|
||||||
</button>
|
<div className="account-dialog-step-label">
|
||||||
))}
|
{accountDialogOption ? `2. Gib Deine ${accountDialogOption.serviceLabel}-Zugangsdaten ein` : "2. Zugangsdaten eingeben"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="account-dialog-step">
|
|
||||||
<div className="account-dialog-step-label">
|
|
||||||
{accountDialogOption ? `2. ${accountDialogOption.serviceLabel} - Zugangsdaten eingeben` : "2. Zugangsdaten eingeben"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!accountDialogOption && (
|
{!accountDialogOption && (
|
||||||
<div className="account-empty-state compact">
|
<div className="account-empty-state compact">
|
||||||
@@ -6502,14 +6377,9 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{accountDialogOption && (
|
{accountDialogOption && (
|
||||||
<>
|
<>
|
||||||
<div className="account-form-summary">
|
<div className="account-modal-fields">
|
||||||
<strong>{accountDialogOption.title}</strong>
|
|
||||||
<span>{accountDialogOption.pickerDescription}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="account-modal-fields">
|
|
||||||
{(accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web") && (
|
{(accountDialogOption.service === "megadebrid-api" || accountDialogOption.service === "megadebrid-web") && (
|
||||||
<div>
|
<div>
|
||||||
<label>Account hinzufügen</label>
|
<label>Account hinzufügen</label>
|
||||||
@@ -6730,7 +6600,7 @@ export function App(): ReactElement {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions account-modal-actions">
|
||||||
<button className="btn" onClick={closeAccountDialog}>Abbrechen</button>
|
<button className="btn" onClick={closeAccountDialog}>Abbrechen</button>
|
||||||
{accountDialog.kind === "realdebrid-web" && (
|
{accountDialog.kind === "realdebrid-web" && (
|
||||||
<button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("realdebrid-login"); }}>
|
<button className="btn" disabled={actionBusy} onClick={() => { void onSaveAccountDialog("realdebrid-login"); }}>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { DebridProvider } from "../shared/types";
|
||||||
|
|
||||||
export type AccountModeFilter = "all" | "api" | "web";
|
export type AccountModeFilter = "all" | "api" | "web";
|
||||||
|
|
||||||
export interface AccountModeOption {
|
export interface AccountModeOption {
|
||||||
@@ -13,3 +15,63 @@ export function matchesAccountModeFilter(option: AccountModeOption, filter: Acco
|
|||||||
}
|
}
|
||||||
return option.modeLabel.startsWith("Web");
|
return option.modeLabel.startsWith("Web");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildConfiguredProviderOrder(
|
||||||
|
currentOrder: readonly DebridProvider[],
|
||||||
|
configuredProviders: readonly DebridProvider[]
|
||||||
|
): DebridProvider[] {
|
||||||
|
const configured = new Set(configuredProviders);
|
||||||
|
const result = currentOrder.filter((provider, index) => configured.has(provider) && currentOrder.indexOf(provider) === index);
|
||||||
|
const included = new Set(result);
|
||||||
|
for (const provider of configuredProviders) {
|
||||||
|
if (!included.has(provider)) {
|
||||||
|
result.push(provider);
|
||||||
|
included.add(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneAccountRowSelection(selectedRowKey: string | null, existingRowKeys: readonly string[]): string | null {
|
||||||
|
return selectedRowKey && existingRowKeys.includes(selectedRowKey) ? selectedRowKey : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVisibleAccountKind<T extends string>(currentKind: T | null, visibleKinds: readonly T[]): T | null {
|
||||||
|
return currentKind && visibleKinds.includes(currentKind) ? currentKind : visibleKinds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccountDialogSelectableOptions<T extends { service: string }>(
|
||||||
|
allOptions: readonly T[],
|
||||||
|
availableOptions: readonly T[],
|
||||||
|
mode: "create" | "edit",
|
||||||
|
editService: string | null
|
||||||
|
): T[] {
|
||||||
|
return mode === "edit"
|
||||||
|
? allOptions.filter((option) => option.service === editService)
|
||||||
|
: [...availableOptions];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAccountRowSelectionKey(key: string, originatedOnRow: boolean): boolean {
|
||||||
|
return originatedOnRow && (key === "Enter" || key === " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBulkAccountEnabledState(
|
||||||
|
currentDisabledProviders: DebridProvider[],
|
||||||
|
configuredProviders: DebridProvider[],
|
||||||
|
megaAccountIds: string[],
|
||||||
|
debridLinkKeyIds: string[],
|
||||||
|
enabled: boolean
|
||||||
|
): {
|
||||||
|
disabledProviders: DebridProvider[];
|
||||||
|
megaDebridDisabledAccountIds: string[];
|
||||||
|
debridLinkDisabledKeyIds: string[];
|
||||||
|
} {
|
||||||
|
const configured = new Set(configuredProviders);
|
||||||
|
return {
|
||||||
|
disabledProviders: enabled
|
||||||
|
? currentDisabledProviders.filter((provider) => !configured.has(provider))
|
||||||
|
: [...new Set([...currentDisabledProviders, ...configuredProviders])],
|
||||||
|
megaDebridDisabledAccountIds: enabled ? [] : [...new Set(megaAccountIds)],
|
||||||
|
debridLinkDisabledKeyIds: enabled ? [] : [...new Set(debridLinkKeyIds)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+350
-206
@@ -1227,26 +1227,117 @@ body,
|
|||||||
background: var(--tab-active);
|
background: var(--tab-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-content {
|
.settings-content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0 4px 14px;
|
padding: 4px 0 4px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-content-accounts {
|
||||||
|
overflow: hidden;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-settings-layout.rules {
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-management-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-management-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-management-heading .hint {
|
||||||
|
margin-top: 1px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-use-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-management-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: color-mix(in srgb, var(--card) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-management-tabs button {
|
||||||
|
height: 27px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-management-tabs button.active {
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-overview-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-overview-panel[hidden],
|
||||||
|
.account-rule-section[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-rule-section {
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-section {
|
.account-board {
|
||||||
display: flex;
|
gap: 12px;
|
||||||
flex-direction: column;
|
}
|
||||||
gap: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-settings-layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-board {
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-board-header {
|
.account-board-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1893,49 +1984,54 @@ body,
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-modal {
|
.account-modal {
|
||||||
width: min(960px, calc(100vw - 36px));
|
width: min(720px, calc(100vw - 36px));
|
||||||
max-height: calc(100vh - 36px);
|
max-height: calc(100vh - 36px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
gap: 14px;
|
gap: 8px;
|
||||||
}
|
padding: 12px;
|
||||||
|
}
|
||||||
.account-modal-header {
|
|
||||||
display: flex;
|
.account-modal-header {
|
||||||
justify-content: space-between;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 12px;
|
min-height: 24px;
|
||||||
flex-wrap: wrap;
|
}
|
||||||
}
|
|
||||||
|
.account-modal-header h3 {
|
||||||
.account-modal-header > div:first-child {
|
margin: 0;
|
||||||
flex: 1;
|
font-size: 15px;
|
||||||
min-width: min(320px, 100%);
|
}
|
||||||
}
|
|
||||||
|
.account-dialog-step {
|
||||||
.account-modal-header p {
|
display: grid;
|
||||||
margin: 4px 0 0;
|
gap: 6px;
|
||||||
color: var(--muted);
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dialog-step {
|
.account-dialog-step-label {
|
||||||
display: grid;
|
font-size: 12px;
|
||||||
gap: 12px;
|
font-weight: 700;
|
||||||
align-content: start;
|
}
|
||||||
}
|
|
||||||
|
.account-modal-body {
|
||||||
.account-dialog-step-label {
|
display: grid;
|
||||||
font-size: 14px;
|
grid-template-rows: minmax(220px, 1fr) auto;
|
||||||
font-weight: 700;
|
gap: 8px;
|
||||||
}
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
.account-modal-body {
|
}
|
||||||
display: grid;
|
|
||||||
gap: 16px;
|
.account-modal-provider-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-y: auto;
|
}
|
||||||
padding-right: 4px;
|
|
||||||
}
|
.account-modal-credentials {
|
||||||
|
max-height: min(320px, 42vh);
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-picker-search {
|
.account-picker-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1943,8 +2039,8 @@ body,
|
|||||||
|
|
||||||
.account-picker-toolbar {
|
.account-picker-toolbar {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(145px, 0.32fr) minmax(0, 1fr);
|
grid-template-columns: 130px minmax(0, 1fr);
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-filter {
|
.account-picker-filter {
|
||||||
@@ -1966,22 +2062,22 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.account-modal label {
|
.account-modal label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 3px;
|
||||||
color: color-mix(in srgb, var(--text) 92%, var(--muted));
|
color: color-mix(in srgb, var(--text) 92%, var(--muted));
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-search,
|
.account-picker-search,
|
||||||
.account-modal input:not([type="checkbox"]):not([type="radio"]),
|
.account-modal input:not([type="checkbox"]):not([type="radio"]),
|
||||||
.account-modal select,
|
.account-modal select,
|
||||||
.account-modal textarea {
|
.account-modal textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 42px;
|
min-height: 30px;
|
||||||
padding: 10px 12px;
|
padding: 5px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 5px;
|
||||||
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||||
background: linear-gradient(180deg, color-mix(in srgb, var(--field) 97%, transparent), color-mix(in srgb, var(--surface) 94%, transparent));
|
background: linear-gradient(180deg, color-mix(in srgb, var(--field) 97%, transparent), color-mix(in srgb, var(--surface) 94%, transparent));
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -2026,14 +2122,15 @@ body,
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-table {
|
.account-picker-table {
|
||||||
display: grid;
|
display: grid;
|
||||||
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border) 84%, transparent);
|
||||||
border-radius: 14px;
|
border-radius: 4px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: min(330px, 40vh);
|
min-height: 190px;
|
||||||
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 94%, transparent));
|
max-height: min(330px, 42vh);
|
||||||
}
|
background: linear-gradient(180deg, color-mix(in srgb, var(--card) 96%, transparent), color-mix(in srgb, var(--surface) 94%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
.account-picker-head,
|
.account-picker-head,
|
||||||
.account-picker-row {
|
.account-picker-row {
|
||||||
@@ -2043,8 +2140,9 @@ body,
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-head {
|
.account-picker-head {
|
||||||
padding: 10px 14px;
|
min-height: 24px;
|
||||||
|
padding: 4px 8px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@@ -2056,11 +2154,12 @@ body,
|
|||||||
background: color-mix(in srgb, var(--card) 98%, transparent);
|
background: color-mix(in srgb, var(--card) 98%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-row {
|
.account-picker-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
min-height: 24px;
|
||||||
text-align: left;
|
padding: 2px 8px;
|
||||||
|
text-align: left;
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2069,10 +2168,10 @@ body,
|
|||||||
transition: border-color 0.12s ease, transform 0.12s ease, background 0.12s ease;
|
transition: border-color 0.12s ease, transform 0.12s ease, background 0.12s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dialog-step + .account-dialog-step {
|
.account-dialog-step + .account-dialog-step {
|
||||||
padding-top: 4px;
|
padding-top: 7px;
|
||||||
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
border-top: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-row:hover {
|
.account-picker-row:hover {
|
||||||
background: color-mix(in srgb, var(--button-bg-hover) 90%, transparent);
|
background: color-mix(in srgb, var(--button-bg-hover) 90%, transparent);
|
||||||
@@ -2083,53 +2182,60 @@ body,
|
|||||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-picker-row > div {
|
.account-picker-domain {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
}
|
gap: 6px;
|
||||||
|
}
|
||||||
.account-picker-row strong {
|
|
||||||
font-size: 13px;
|
.account-picker-row strong {
|
||||||
}
|
overflow: hidden;
|
||||||
|
font-size: 11.5px;
|
||||||
.account-picker-row span {
|
font-weight: 500;
|
||||||
color: var(--muted);
|
text-overflow: ellipsis;
|
||||||
font-size: 12px;
|
white-space: nowrap;
|
||||||
line-height: 1.45;
|
}
|
||||||
}
|
|
||||||
|
.account-picker-function {
|
||||||
.account-form-summary {
|
overflow: hidden;
|
||||||
display: grid;
|
color: var(--muted);
|
||||||
gap: 4px;
|
font-size: 11px;
|
||||||
padding: 12px 14px;
|
text-overflow: ellipsis;
|
||||||
border-radius: 12px;
|
white-space: nowrap;
|
||||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
}
|
||||||
background: color-mix(in srgb, var(--field) 84%, transparent);
|
|
||||||
}
|
.account-picker-icon,
|
||||||
|
.acct2-service-icon {
|
||||||
.account-form-summary span {
|
display: inline-grid;
|
||||||
color: var(--muted);
|
place-items: center;
|
||||||
font-size: 13px;
|
width: 15px;
|
||||||
}
|
height: 15px;
|
||||||
|
flex: 0 0 15px;
|
||||||
.account-modal-fields {
|
border-radius: 3px;
|
||||||
display: grid;
|
background: color-mix(in srgb, var(--accent) 24%, var(--field));
|
||||||
gap: 12px;
|
color: color-mix(in srgb, var(--accent) 78%, var(--text));
|
||||||
}
|
font-size: 9px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-modal-fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-modal-note {
|
.account-modal-note {
|
||||||
padding: 12px 14px;
|
padding: 6px 8px;
|
||||||
border-radius: 12px;
|
border-radius: 5px;
|
||||||
border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
|
border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
|
||||||
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
line-height: 1.5;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-daily-limit-note {
|
.account-daily-limit-note {
|
||||||
margin-top: 10px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-mode-note {
|
.account-mode-note {
|
||||||
@@ -2146,18 +2252,18 @@ body,
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dl-key-limit-list {
|
.account-dl-key-limit-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 8px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dl-key-limit-row {
|
.account-dl-key-limit-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
|
grid-template-columns: minmax(160px, 220px) minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 5px 7px;
|
||||||
border-radius: 12px;
|
border-radius: 5px;
|
||||||
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
border: 1px solid color-mix(in srgb, var(--border) 82%, transparent);
|
||||||
background: color-mix(in srgb, var(--field) 84%, transparent);
|
background: color-mix(in srgb, var(--field) 84%, transparent);
|
||||||
}
|
}
|
||||||
@@ -2168,13 +2274,13 @@ body,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dl-key-meta strong {
|
.account-dl-key-meta strong {
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-dl-key-meta span {
|
.account-dl-key-meta span {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
font-family: "JetBrains Mono", "Consolas", monospace;
|
font-family: "JetBrains Mono", "Consolas", monospace;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -3100,8 +3206,8 @@ td {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-card {
|
.modal-card {
|
||||||
width: min(560px, 100%);
|
width: min(560px, 100%);
|
||||||
max-height: calc(100vh - 40px);
|
max-height: calc(100vh - 40px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -3110,10 +3216,14 @@ td {
|
|||||||
box-shadow: 0 20px 38px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 20px 38px rgba(0, 0, 0, 0.35);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-card h3 {
|
.modal-card.account-modal {
|
||||||
|
width: min(660px, calc(100vw - 36px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3156,10 +3266,22 @@ td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-modal-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding-top: 6px;
|
||||||
|
background: color-mix(in srgb, var(--card) 96%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-modal-actions > :first-child {
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.online-backup-modal {
|
.online-backup-modal {
|
||||||
@@ -3359,22 +3481,22 @@ td {
|
|||||||
.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; }
|
.account-validity-badge.disabled { color: var(--muted, #a59c8e); background: transparent; border-color: var(--border); opacity: 0.8; }
|
||||||
|
|
||||||
.acct2-table {
|
.acct2-table {
|
||||||
display: flex;
|
display: block;
|
||||||
flex-direction: column;
|
flex: 1;
|
||||||
border: 1px solid var(--border);
|
min-height: 0;
|
||||||
border-radius: 10px;
|
border-bottom: 1px solid var(--border);
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
margin-top: 4px;
|
}
|
||||||
}
|
|
||||||
.acct2-head,
|
.acct2-head,
|
||||||
.acct2-row {
|
.acct2-row {
|
||||||
display: grid;
|
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;
|
grid-template-columns: 24px minmax(150px, 1.25fr) minmax(165px, 1.3fr) minmax(190px, 1.5fr) minmax(165px, 1.3fr) minmax(100px, 0.72fr) minmax(140px, 0.9fr) 30px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 6px;
|
||||||
padding: 7px 12px;
|
min-width: 1120px;
|
||||||
}
|
padding: 0 6px;
|
||||||
|
}
|
||||||
.acct2-head > span,
|
.acct2-head > span,
|
||||||
.acct2-row > span {
|
.acct2-row > span {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -3383,50 +3505,72 @@ td {
|
|||||||
}
|
}
|
||||||
.acct2-head > .acct2-c-actions,
|
.acct2-head > .acct2-c-actions,
|
||||||
.acct2-row > .acct2-c-actions { overflow: visible; }
|
.acct2-row > .acct2-c-actions { overflow: visible; }
|
||||||
.acct2-head {
|
.acct2-head {
|
||||||
background: color-mix(in srgb, var(--card) 60%, transparent);
|
position: sticky;
|
||||||
border-bottom: 1px solid var(--border);
|
top: 0;
|
||||||
font-size: 11px;
|
z-index: 2;
|
||||||
|
min-height: 25px;
|
||||||
|
background: color-mix(in srgb, var(--card) 60%, transparent);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 9.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.acct2-row {
|
.acct2-row {
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
min-height: 24px;
|
||||||
font-size: 13px;
|
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||||
}
|
font-size: 11px;
|
||||||
.acct2-row:last-child { border-bottom: 0; }
|
cursor: default;
|
||||||
.acct2-row.acct2-problem { background: color-mix(in srgb, var(--danger) 15%, transparent); }
|
}
|
||||||
.acct2-row.acct2-disabled { opacity: 0.55; }
|
.acct2-row:last-child { border-bottom: 0; }
|
||||||
.acct2-group-head { cursor: pointer; background: color-mix(in srgb, var(--card) 50%, transparent); }
|
.acct2-row:hover { background: color-mix(in srgb, var(--button-bg-hover) 55%, transparent); }
|
||||||
.acct2-group-head:hover { background: color-mix(in srgb, var(--card) 68%, transparent); }
|
.acct2-row.selected { background: color-mix(in srgb, var(--accent) 15%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); }
|
||||||
.acct2-group-head .acct2-hoster strong { font-size: 13px; }
|
.acct2-row.acct2-problem { background: color-mix(in srgb, var(--danger) 15%, transparent); }
|
||||||
.acct2-chevron { display: inline-block; color: var(--muted); font-size: 9px; transition: transform 0.12s ease; }
|
.acct2-row.acct2-problem.selected { background: color-mix(in srgb, var(--danger) 20%, var(--accent) 8%); }
|
||||||
.acct2-chevron.open { transform: rotate(90deg); }
|
.acct2-row.acct2-disabled { opacity: 0.55; }
|
||||||
.acct2-grp-sum { display: flex; gap: 6px; flex-wrap: wrap; }
|
.acct2-body { min-width: 1120px; }
|
||||||
.acct2-row.acct2-member .acct2-hoster {
|
.acct2-empty { display: grid; place-items: center; min-height: 160px; color: var(--muted); font-size: 11px; }
|
||||||
padding-left: 12px;
|
.acct2-c-check { display: flex; justify-content: center; }
|
||||||
border-left: 2px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
.acct2-c-check input { cursor: pointer; }
|
||||||
}
|
|
||||||
.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-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-menu-button { width: 22px; min-width: 22px; height: 19px; padding: 0; font-size: 14px; line-height: 1; }
|
||||||
.acct2-hoster { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
.acct2-hoster { display: grid; grid-template-columns: 15px auto minmax(0, 1fr); align-items: center; gap: 5px; min-width: 0; }
|
||||||
.acct2-hoster strong { font-size: 13px; }
|
.acct2-hoster strong { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.acct2-mode { font-size: 11px; color: var(--muted); }
|
.acct2-mode { overflow: hidden; color: var(--muted); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.acct2-traffic,
|
.acct2-traffic,
|
||||||
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
|
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||||
.acct2-head > span:nth-child(3),
|
.acct2-head > span:nth-child(3),
|
||||||
.acct2-traffic { text-align: center; }
|
.acct2-traffic { text-align: center; }
|
||||||
.acct2-sortable { cursor: pointer; user-select: none; }
|
.acct2-sortable { width: 100%; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; letter-spacing: inherit; text-align: left; text-transform: inherit; cursor: pointer; user-select: none; }
|
||||||
.acct2-sortable:hover { color: var(--text); }
|
.acct2-sortable:hover { color: var(--text); }
|
||||||
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.acct2-credential { color: var(--muted); white-space: nowrap; }
|
.acct2-credential { color: var(--muted); white-space: nowrap; }
|
||||||
.acct2-status .account-validity-badge { margin-top: 0; }
|
.acct2-status .account-validity-badge { height: 16px; margin-top: 0; padding: 0 5px; font-size: 9px; line-height: 14px; }
|
||||||
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
|
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.account-table-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: color-mix(in srgb, var(--card) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-table-actions > span {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-table-actions .btn-sm {
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.account-context-menu { min-width: 230px; }
|
.account-context-menu { min-width: 230px; }
|
||||||
.account-context-heading { display: grid; gap: 3px; padding: 8px 12px; }
|
.account-context-heading { display: grid; gap: 3px; padding: 8px 12px; }
|
||||||
.account-context-heading strong { color: var(--text); font-size: 13px; }
|
.account-context-heading strong { color: var(--text); font-size: 13px; }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ function megaDialog(kind: "megadebrid-api" | "megadebrid-web"): AccountDialogSta
|
|||||||
return {
|
return {
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
kind,
|
kind,
|
||||||
|
service: kind,
|
||||||
token: "",
|
token: "",
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { matchesAccountModeFilter } from "../src/renderer/account-ui";
|
import {
|
||||||
|
buildBulkAccountEnabledState,
|
||||||
|
buildConfiguredProviderOrder,
|
||||||
|
getAccountDialogSelectableOptions,
|
||||||
|
isAccountRowSelectionKey,
|
||||||
|
matchesAccountModeFilter,
|
||||||
|
pruneAccountRowSelection,
|
||||||
|
resolveVisibleAccountKind
|
||||||
|
} from "../src/renderer/account-ui";
|
||||||
|
|
||||||
describe("account mode filter", () => {
|
describe("account mode filter", () => {
|
||||||
it("shows only API options for the API filter", () => {
|
it("shows only API options for the API filter", () => {
|
||||||
@@ -12,3 +20,72 @@ describe("account mode filter", () => {
|
|||||||
expect(matchesAccountModeFilter({ modeLabel: "API" }, "web")).toBe(false);
|
expect(matchesAccountModeFilter({ modeLabel: "API" }, "web")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("bulk account activation", () => {
|
||||||
|
it("disables each configured provider and account exactly once", () => {
|
||||||
|
expect(buildBulkAccountEnabledState(
|
||||||
|
["alldebrid"],
|
||||||
|
["megadebrid-api", "alldebrid"],
|
||||||
|
["mega-1", "mega-1", "mega-2"],
|
||||||
|
["debrid-link-1", "debrid-link-1"],
|
||||||
|
false
|
||||||
|
)).toEqual({
|
||||||
|
disabledProviders: ["alldebrid", "megadebrid-api"],
|
||||||
|
megaDebridDisabledAccountIds: ["mega-1", "mega-2"],
|
||||||
|
debridLinkDisabledKeyIds: ["debrid-link-1"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables configured providers without changing unrelated provider state", () => {
|
||||||
|
expect(buildBulkAccountEnabledState(
|
||||||
|
["realdebrid", "alldebrid", "megadebrid-api"],
|
||||||
|
["alldebrid", "megadebrid-api"],
|
||||||
|
["mega-1"],
|
||||||
|
["debrid-link-1"],
|
||||||
|
true
|
||||||
|
)).toEqual({
|
||||||
|
disabledProviders: ["realdebrid"],
|
||||||
|
megaDebridDisabledAccountIds: [],
|
||||||
|
debridLinkDisabledKeyIds: []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the custom provider order while accounts are disabled", () => {
|
||||||
|
expect(buildConfiguredProviderOrder(
|
||||||
|
["debridlink", "realdebrid", "alldebrid"],
|
||||||
|
["realdebrid", "alldebrid", "debridlink", "bestdebrid"]
|
||||||
|
)).toEqual(["debridlink", "realdebrid", "alldebrid", "bestdebrid"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("account selection", () => {
|
||||||
|
it("clears a selected row after that account disappears", () => {
|
||||||
|
expect(pruneAccountRowSelection("account-a", ["account-b"])).toBeNull();
|
||||||
|
expect(pruneAccountRowSelection("account-a", ["account-a", "account-b"])).toBe("account-a");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a hidden provider selection to the first visible option", () => {
|
||||||
|
expect(resolveVisibleAccountKind("realdebrid-api", ["realdebrid-web", "bestdebrid-web"]))
|
||||||
|
.toBe("realdebrid-web");
|
||||||
|
expect(resolveVisibleAccountKind("realdebrid-api", [])).toBeNull();
|
||||||
|
expect(resolveVisibleAccountKind("realdebrid-api", ["realdebrid-api", "bestdebrid-api"]))
|
||||||
|
.toBe("realdebrid-api");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps edit options available after a filter temporarily has no matches", () => {
|
||||||
|
const options = [
|
||||||
|
{ kind: "realdebrid-api", service: "realdebrid" },
|
||||||
|
{ kind: "realdebrid-web", service: "realdebrid" },
|
||||||
|
{ kind: "bestdebrid-api", service: "bestdebrid" }
|
||||||
|
];
|
||||||
|
expect(getAccountDialogSelectableOptions(options, [], "edit", "realdebrid").map((option) => option.kind))
|
||||||
|
.toEqual(["realdebrid-api", "realdebrid-web"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not consume keyboard activation from controls inside a row", () => {
|
||||||
|
expect(isAccountRowSelectionKey("Enter", true)).toBe(true);
|
||||||
|
expect(isAccountRowSelectionKey(" ", true)).toBe(true);
|
||||||
|
expect(isAccountRowSelectionKey("Enter", false)).toBe(false);
|
||||||
|
expect(isAccountRowSelectionKey(" ", false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user