release: publish Multi-Debrid Downloader v2.0.14

Add live English and German localization, queue availability and metadata resolution, responsive package controls, polished navigation and drag interactions, clearer history and account states, and a rebuilt public README. Harden Windows packaging with verified icons and version metadata, archive inspection, and expanded release tests.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:42:49 +02:00
parent 069babfd54
commit a5758aa905
61 changed files with 9117 additions and 6754 deletions
+6
View File
@@ -0,0 +1,6 @@
import path from "node:path";
export function resolveAppIconPath(isPackaged: boolean, appPath: string, resourcesPath: string): string {
const basePath = isPackaged ? resourcesPath : appPath;
return path.join(basePath, "assets", "app_icon.ico");
}
+3 -1
View File
@@ -43,6 +43,7 @@ export const ONLINE_BACKUP_API_URL = "https://downloader.24-music.de/backup-api"
export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Desktop", "Multi-Debrid-Downloader");
return {
language: "en",
token: "",
realDebridUseWebLogin: false,
megaLogin: "",
@@ -120,7 +121,8 @@ export function defaultSettings(): AppSettings {
totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0,
bandwidthSchedules: [],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
columnOrderVersion: 3,
extractCpuPriority: "high",
autoExtractWhenStopped: true,
disabledProviders: [],
+17 -47
View File
@@ -1697,13 +1697,23 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
export interface RapidgatorCheckResult {
online: boolean;
fileName: string;
fileSize: string | null;
fileSizeBytes: number | null;
}
const RG_FILE_ID_RE = /\/file\/([a-z0-9]{32}|\d+)/i;
const RG_FILE_NOT_FOUND_RE = />\s*404\s*File not found/i;
const RG_FILESIZE_RE = /File\s*size:\s*<strong>([^<>"]+)<\/strong>/i;
export function parseRapidgatorFileSize(value: string | null | undefined): number | null {
const match = String(value ?? "").trim().match(/^([\d.,]+)\s*(B|KB|KIB|MB|MIB|GB|GIB|TB|TIB)$/i);
if (!match) return null;
const amount = Number(match[1].replace(/,/g, "."));
if (!Number.isFinite(amount) || amount < 0) return null;
const powers: Record<string, number> = { B: 0, KB: 1, KIB: 1, MB: 2, MIB: 2, GB: 3, GIB: 3, TB: 4, TIB: 4 };
const bytes = Math.round(amount * (1024 ** powers[match[2].toUpperCase()]));
return Number.isSafeInteger(bytes) ? bytes : null;
}
export async function checkRapidgatorOnline(
link: string,
signal?: AbortSignal
@@ -1723,47 +1733,7 @@ export async function checkRapidgatorOnline(
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
};
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
const response = await fetch(link, {
method: "HEAD",
redirect: "follow",
headers,
signal: withTimeoutSignal(signal, 15000)
});
if (response.status === 404) {
return { online: false, fileName: "", fileSize: null };
}
if (response.ok) {
const finalUrl = response.url || link;
if (!finalUrl.includes(fileId)) {
return { online: false, fileName: "", fileSize: null };
}
const fileName = filenameFromRapidgatorUrlPath(link);
return { online: true, fileName, fileSize: null };
}
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
break;
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
@@ -1776,7 +1746,7 @@ export async function checkRapidgatorOnline(
if (response.status === 404) {
try { await response.body?.cancel(); } catch { }
return { online: false, fileName: "", fileSize: null };
return { online: false, fileName: "", fileSizeBytes: null };
}
if (!response.ok) {
@@ -1791,20 +1761,20 @@ export async function checkRapidgatorOnline(
const finalUrl = response.url || link;
if (!finalUrl.includes(fileId)) {
try { await response.body?.cancel(); } catch { }
return { online: false, fileName: "", fileSize: null };
return { online: false, fileName: "", fileSizeBytes: null };
}
const html = await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal);
if (RG_FILE_NOT_FOUND_RE.test(html)) {
return { online: false, fileName: "", fileSize: null };
return { online: false, fileName: "", fileSizeBytes: null };
}
const fileName = extractRapidgatorFilenameFromHtml(html) || filenameFromRapidgatorUrlPath(link);
const sizeMatch = html.match(RG_FILESIZE_RE);
const fileSize = sizeMatch ? sizeMatch[1].trim() : null;
const fileSizeBytes = parseRapidgatorFileSize(sizeMatch?.[1]);
return { online: true, fileName, fileSize };
return { online: true, fileName, fileSizeBytes };
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
+2
View File
@@ -0,0 +1,2 @@
export const DEV_SERVER_PORT = process.env.DEV_SERVER_PORT || "5180";
export const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
+48 -34
View File
@@ -605,7 +605,7 @@ export function getAuthoritativeRealDebridTotal(
if (!resumeHardResetUsed || source !== "content-length") {
return null;
}
} else {
} else {
return null;
}
@@ -1696,14 +1696,27 @@ function formatExtractFailureLabel(reason: string, archiveName = ""): string {
: `Entpack-Fehler: ${summary}`;
}
function retryDelayWithJitter(attempt: number, baseMs: number): number {
function retryDelayWithJitter(attempt: number, baseMs: number): number {
const exponential = baseMs * Math.pow(1.5, Math.min(attempt - 1, 14));
const capped = Math.min(exponential, 120000);
const jitter = capped * (0.5 + Math.random() * 0.5);
return Math.floor(jitter);
}
export class DownloadManager extends EventEmitter {
return Math.floor(jitter);
}
export async function runWithLimitedConcurrency<T>(items: readonly T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
const workerCount = Math.min(items.length, Math.max(1, Math.floor(concurrency)));
let nextIndex = 0;
const workers = Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await worker(item);
}
});
await Promise.all(workers);
}
export class DownloadManager extends EventEmitter {
private settings: AppSettings;
private session: SessionState;
@@ -3278,31 +3291,28 @@ export class DownloadManager extends EventEmitter {
this.emitState();
}
const checkedUrls = new Map<string, Awaited<ReturnType<typeof checkRapidgatorOnline>>>();
for (const { itemId, url } of itemsToCheck) {
const item = this.session.items[itemId];
if (!item) continue;
if (checkedUrls.has(url)) {
const cached = checkedUrls.get(url);
if (cached !== undefined) {
this.applyRapidgatorCheckResult(item, cached);
}
this.emitState();
continue;
}
try {
const result = await checkRapidgatorOnline(url);
checkedUrls.set(url, result);
this.applyRapidgatorCheckResult(item, result);
} catch (err) {
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
item.onlineStatus = undefined;
}
this.emitState();
}
const checkedUrls = new Map<string, ReturnType<typeof checkRapidgatorOnline>>();
await runWithLimitedConcurrency(itemsToCheck, 8, async ({ itemId, url }) => {
const item = this.session.items[itemId];
if (!item) return;
let check = checkedUrls.get(url);
if (!check) {
check = checkRapidgatorOnline(url);
checkedUrls.set(url, check);
}
try {
const result = await check;
this.applyRapidgatorCheckResult(item, result);
} catch (err) {
logger.warn(`checkRapidgatorOnline Fehler für ${url}: ${compactErrorText(err)}`);
item.onlineStatus = undefined;
}
this.persistSoon();
this.emitState();
});
this.persistSoon();
}
@@ -3354,8 +3364,11 @@ export class DownloadManager extends EventEmitter {
if (result.fileName && looksLikeOpaqueFilename(item.fileName)) {
item.fileName = sanitizeFilename(result.fileName);
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName));
}
item.onlineStatus = "online";
}
if (result.fileSizeBytes !== null && result.fileSizeBytes > 0) {
item.totalBytes = result.fileSizeBytes;
}
item.onlineStatus = "online";
item.updatedAt = nowMs();
}
}
@@ -3364,7 +3377,8 @@ export class DownloadManager extends EventEmitter {
const uncheckedIds: string[] = [];
for (const item of Object.values(this.session.items)) {
if (item.status !== "queued") continue;
if (item.onlineStatus) continue;
if (item.onlineStatus === "offline") continue;
if (item.onlineStatus === "online" && item.totalBytes !== null && item.totalBytes > 0) continue;
try {
const host = new URL(item.url).hostname.toLowerCase();
if (host !== "rapidgator.net" && !host.endsWith(".rapidgator.net") && host !== "rg.to" && !host.endsWith(".rg.to")) continue;
+5 -3
View File
@@ -11,6 +11,8 @@ import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
import { revealHistoryEntry } from "./history-reveal";
import { DEV_SERVER_URL } from "./dev-server-url";
import { resolveAppIconPath } from "./app-icon";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -115,7 +117,7 @@ function createWindow(): BrowserWindow {
minHeight: 760,
backgroundColor: "#070b14",
title: `${APP_NAME} - v${controller.getVersion()}`,
icon: path.join(app.getAppPath(), "assets", "app_icon.ico"),
icon: resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath),
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@@ -140,7 +142,7 @@ function createWindow(): BrowserWindow {
window.setAutoHideMenuBar(true);
if (isDevMode()) {
void window.loadURL("http://localhost:5173");
void window.loadURL(DEV_SERVER_URL);
} else {
void window.loadFile(path.join(app.getAppPath(), "build", "renderer", "index.html"));
}
@@ -212,7 +214,7 @@ function createTray(): void {
if (tray) {
return;
}
const iconPath = path.join(app.getAppPath(), "assets", "app_icon.ico");
const iconPath = resolveAppIconPath(app.isPackaged, app.getAppPath(), process.resourcesPath);
try {
tray = new Tray(iconPath);
} catch (error) {
+22 -13
View File
@@ -135,10 +135,10 @@ function migrateLegacyDefaultDirectories(settings: AppSettings, defaults: AppSet
};
}
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
function normalizeColumnOrder(raw: unknown): string[] {
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
const ALL_VALID_COLUMNS = new Set([...DEFAULT_COLUMN_ORDER, "added"]);
function normalizeColumnOrder(raw: unknown, version: unknown): string[] {
if (!Array.isArray(raw) || raw.length === 0) {
return [...DEFAULT_COLUMN_ORDER];
}
@@ -151,10 +151,14 @@ function normalizeColumnOrder(raw: unknown): string[] {
result.push(col);
}
}
if (!seen.has("name")) {
result.unshift("name");
}
return result;
if (!seen.has("name")) {
result.unshift("name");
}
if (version !== 3 && !seen.has("availability")) {
const speedIndex = result.indexOf("speed");
result.splice(speedIndex >= 0 ? speedIndex + 1 : result.length, 0, "availability");
}
return result;
}
function getPreferredMegaDebridProvider(megaDebridPreferApi: boolean, megaDebridApiEnabled: boolean, megaDebridWebEnabled: boolean): DebridProvider {
@@ -421,6 +425,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
);
const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds);
const normalized: AppSettings = {
language: settings.language === "de" ? "de" : "en",
token: asText(settings.token),
realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin),
megaLogin,
@@ -506,7 +511,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
theme: VALID_THEMES.has(settings.theme) ? settings.theme : defaults.theme,
bandwidthSchedules: normalizeBandwidthSchedules(settings.bandwidthSchedules),
columnOrder: normalizeColumnOrder(settings.columnOrder),
columnOrder: normalizeColumnOrder(settings.columnOrder, settings.columnOrderVersion),
columnOrderVersion: 3,
extractCpuPriority: settings.extractCpuPriority,
autoExtractWhenStopped: settings.autoExtractWhenStopped !== undefined ? Boolean(settings.autoExtractWhenStopped) : defaults.autoExtractWhenStopped,
disabledProviders: normalizeDisabledProviders(settings.disabledProviders),
@@ -677,10 +683,13 @@ function migrateLegacyMegaEnableFlags(parsed: AppSettings): AppSettings {
function readSettingsFile(filePath: string): AppSettings | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as AppSettings;
const merged = normalizeSettings({
...defaultSettings(),
...migrateLegacyMegaEnableFlags(parsed)
});
const migratedLanguage = (parsed as Partial<AppSettings>).language === undefined ? "de" : parsed.language;
const merged = normalizeSettings({
...defaultSettings(),
...migrateLegacyMegaEnableFlags(parsed),
language: migratedLanguage,
columnOrderVersion: parsed.columnOrderVersion
});
return sanitizeCredentialPersistence(merged);
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code || "";
+131 -68
View File
@@ -1,4 +1,5 @@
import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts";
import type {
@@ -36,13 +37,14 @@ import {
getProviderUsageDayKey
} from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { pruneSelection } from "./selection";
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
import type { AccountModeFilter } from "./account-ui";
import { applyAccountEdit, buildAccountEditCheckSettings, createAccountEditState, getAccountEditExpectedStatusId, removeAccountTarget, validateAccountEdit, validateAccountEditStatuses } from "./account-edit";
import type { AccountEditState, AccountEditTarget, AccountKind, AccountService, SingleAccountKind } from "./account-edit";
import { ACCOUNT_SERVICE_ICONS } from "./account-service-icons";
import { DOWNLOAD_SPEED_MAX_SAMPLES, updateDownloadSpeedHistory } from "./download-speed-state";
import { createUiLocalizer, normalizeLanguage } from "./i18n";
import type { DownloadSpeedHistoryState } from "./download-speed-state";
import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format";
import { AppShell } from "./shell/AppShell";
@@ -88,8 +90,9 @@ import {
StatisticsSidebarStatus,
type StatisticsViewActions
} from "./views/statistics/StatisticsView";
import { buildDownloadsViewModel, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
import {
DownloadsContent,
DownloadsFooter,
@@ -1024,8 +1027,8 @@ const emptyStats = (): DownloadStats => ({
});
const emptySnapshot = (): UiSnapshot => ({
settings: {
token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
settings: {
language: "en", token: "", realDebridUseWebLogin: false, megaLogin: "", megaPassword: "", megaCredentials: "", megaDebridApiEnabled: false, megaDebridWebEnabled: false, megaDebridPreferApi: true, bestToken: "", bestDebridUseWebLogin: false, allDebridToken: "", allDebridUseWebLogin: false, ddownloadLogin: "", ddownloadPassword: "", oneFichierApiKey: "", debridLinkApiKeys: "", linkSnappyLogin: "", linkSnappyPassword: "",
debridLinkDisabledKeyIds: [],
archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
@@ -1041,7 +1044,8 @@ const emptySnapshot = (): UiSnapshot => ({
notifyUrl: "", notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false,
accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"],
columnOrderVersion: 3,
autoExtractWhenStopped: true,
disabledProviders: [],
hosterRouting: {},
@@ -1647,8 +1651,8 @@ function computePackageProgress(pkg: PackageEntry | undefined, items: Record<str
return totalSize > 0 ? totalDown / totalSize : 0;
}
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"];
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "added"];
const DEFAULT_COLUMN_ORDER = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"];
const ALL_COLUMN_KEYS = ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability", "added"];
const COLUMN_DEFS = downloadColumnDefinitions;
function sameStringArray(a: string[], b: string[]): boolean {
@@ -1742,7 +1746,12 @@ export function App(): ReactElement {
const [runtimeNow, setRuntimeNow] = useState(() => Date.now());
const updateCheckGenerationRef = useRef(0);
const settingsDirtyRef = useRef(false);
const settingsDraftRevisionRef = useRef(0);
const settingsDraftRevisionRef = useRef(0);
useEffect(() => {
const localizer = createUiLocalizer(document, normalizeLanguage(settingsDraft.language));
return () => localizer.disconnect();
}, [settingsDraft.language]);
const panelDirtyRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null);
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
@@ -1829,11 +1838,16 @@ export function App(): ReactElement {
const [debridLinkHostLimitsLoading, setDebridLinkHostLimitsLoading] = useState(false);
const [debridLinkHostLimitsError, setDebridLinkHostLimitsError] = useState("");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
const [dragColId, setDragColId] = useState<string | null>(null);
const [dropTargetCol, setDropTargetCol] = useState<string | null>(null);
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ ids: Set<string>; dontAsk: boolean } | null>(null);
const [columnOrder, setColumnOrder] = useState<string[]>(() => DEFAULT_COLUMN_ORDER);
const columnDragSessionRef = useRef<DownloadColumnDragSession | null>(null);
const columnDragSettleTimerRef = useRef<number | null>(null);
const suppressColumnSortRef = useRef(false);
useEffect(() => () => {
if (columnDragSettleTimerRef.current !== null) window.clearTimeout(columnDragSettleTimerRef.current);
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
}, []);
const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null);
const colHeaderCtxRef = useRef<HTMLDivElement>(null);
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const historyEntriesRef = useRef<HistoryEntry[]>([]);
@@ -4488,9 +4502,10 @@ export function App(): ReactElement {
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent): void => {
if (e.key === "Escape") {
const target = e.target as HTMLElement;
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
if (e.key === "Escape") {
const target = e.target as HTMLElement;
const inputType = target.tagName === "INPUT" ? (target as HTMLInputElement).type : "";
if (shouldClearDownloadSelectionOnEscape(target.tagName, inputType)) {
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
if (tabRef.current === "downloads") setSelectedIds(new Set());
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
@@ -4503,11 +4518,11 @@ export function App(): ReactElement {
requestDeleteSelection();
}
};
const onDown = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
if (target.closest(".package-card") || target.closest(".ctx-menu") || target.closest(".modal-backdrop") || target.closest(".modal-card")) return;
setSelectedIds(new Set());
};
const onDown = (e: MouseEvent): void => {
const target = e.target as HTMLElement;
if (!shouldClearDownloadSelection(target)) return;
setSelectedIds(new Set());
};
window.addEventListener("keydown", onKey);
window.addEventListener("mousedown", onDown);
return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("mousedown", onDown); };
@@ -4896,6 +4911,34 @@ export function App(): ReactElement {
});
}, [askConfirmPrompt, performQuickAction]);
const setClipboardWatcherActive = useCallback((active: boolean): void => {
const update = (current: UiSnapshot): UiSnapshot => ({
...current,
clipboardActive: active,
settings: { ...current.settings, clipboardWatch: active }
});
const next = update(snapshotRef.current);
snapshotRef.current = next;
masterSnapshotRef.current = masterSnapshotRef.current ? update(masterSnapshotRef.current) : next;
if (latestStateRef.current) {
latestStateRef.current = update(latestStateRef.current);
}
setSnapshot(next);
setSettingsDraft((current) => ({ ...current, clipboardWatch: active }));
}, []);
const toggleClipboardWatcher = useCallback((): void => {
const previous = snapshotRef.current.clipboardActive;
const next = !previous;
setClipboardWatcherActive(next);
void window.rd.toggleClipboard()
.then(setClipboardWatcherActive)
.catch((error) => {
setClipboardWatcherActive(previous);
showToast(`Zwischenablage konnte nicht umgeschaltet werden: ${String(error)}`, 2600);
});
}, [setClipboardWatcherActive, showToast]);
const activateDownloadSchedule = useCallback((): void => {
if (!scheduleTimeInput) return;
const [hours, minutes] = scheduleTimeInput.split(":").map(Number);
@@ -4918,6 +4961,7 @@ export function App(): ReactElement {
}, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]);
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
...downloadsViewCore,
running: snapshot.session.running,
@@ -4944,12 +4988,14 @@ export function App(): ReactElement {
packages: snapshot.stats.totalPackages,
links: Object.keys(snapshot.session.items).length,
session: humanSize(snapshot.stats.totalDownloaded),
total: humanSize(snapshot.stats.totalDownloadedAllTime),
sessionBytes: snapshot.stats.totalDownloaded,
total: humanSize(downloadQueueTotalBytes),
totalBytes: downloadQueueTotalBytes,
hosters: providerStats.length,
speed: snapshot.speedText,
eta: snapshot.etaText
}
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalDownloadedAllTime, snapshot.stats.totalPackages]);
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.speedText, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
const downloadsActions: DownloadsViewActions = {
onDisplayModeChange: setDownloadDisplayMode,
@@ -4988,7 +5034,7 @@ export function App(): ReactElement {
if (entry) onPackageStartEdit(entry.id, entry.name);
},
onRemoveSelection: removeActionableDownloads,
onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); },
onToggleClipboardWatcher: toggleClipboardWatcher,
onClearAll: clearDownloadQueue,
onToggleAllPackages: () => {
const targetState = !allPackagesCollapsed;
@@ -5022,7 +5068,6 @@ export function App(): ReactElement {
onToggleSelection: onSelectId,
onSelectionMouseDown: onSelectMouseDown,
onSelectionMouseEnter: onSelectMouseEnter,
onTogglePackage: onPackageToggle,
onTogglePackageCollapse: onPackageToggleCollapse,
onStartPackageRename: onPackageStartEdit,
onPackageRenameChange: setEditingName,
@@ -5042,35 +5087,53 @@ export function App(): ReactElement {
const item = snapshot.session.items[id];
onPackageContextMenu(packageId ?? item?.packageId ?? id, item?.id, x, y);
},
onSortColumn: sortDownloadsByColumn,
onColumnDragStart: (column, event) => {
event.dataTransfer.effectAllowed = "move";
setDragColId(column);
onSortColumn: (column) => {
if (suppressColumnSortRef.current) return;
sortDownloadsByColumn(column);
},
onColumnDragOver: (column, event) => {
if (dragColId && dragColId !== column) {
event.preventDefault();
setDropTargetCol(column);
onColumnPointerDown: (column, event) => {
if (columnDragSettleTimerRef.current !== null) {
window.clearTimeout(columnDragSettleTimerRef.current);
columnDragSettleTimerRef.current = null;
}
if (columnDragSessionRef.current) clearDownloadColumnDrag(columnDragSessionRef.current);
columnDragSessionRef.current = beginDownloadColumnDrag(event.currentTarget, column, event.pointerId, event.clientX);
},
onColumnDragLeave: () => setDropTargetCol(null),
onColumnDrop: (column, event) => {
event.preventDefault();
setDropTargetCol(null);
if (!dragColId || dragColId === column) return;
const next = [...columnOrder];
const fromIndex = next.indexOf(dragColId);
const toIndex = next.indexOf(column);
if (fromIndex < 0 || toIndex < 0) return;
next.splice(fromIndex, 1);
next.splice(toIndex, 0, dragColId);
setColumnOrder(next);
setDragColId(null);
void window.rd.updateSettings({ columnOrder: next }).catch(() => {});
onColumnPointerMove: (_column, event) => {
const session = columnDragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
if (updateDownloadColumnDrag(session, event.clientX)) event.preventDefault();
},
onColumnDragEnd: () => {
setDragColId(null);
setDropTargetCol(null);
onColumnPointerUp: (_column, event) => {
const session = columnDragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
const next = settleDownloadColumnDrag(session, false);
if (!next) {
columnDragSessionRef.current = null;
return;
}
suppressColumnSortRef.current = true;
window.setTimeout(() => { suppressColumnSortRef.current = false; }, 0);
const changed = next.join("|") !== session.measurements.map((measurement) => measurement.id).join("|");
columnDragSettleTimerRef.current = window.setTimeout(() => {
if (changed) {
flushSync(() => setColumnOrder(next));
void window.rd.updateSettings({ columnOrder: next }).catch(() => {});
}
clearDownloadColumnDrag(session);
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
columnDragSettleTimerRef.current = null;
}, 220);
},
onColumnPointerCancel: (_column, event) => {
const session = columnDragSessionRef.current;
if (!session || session.pointerId !== event.pointerId) return;
settleDownloadColumnDrag(session, true);
columnDragSettleTimerRef.current = window.setTimeout(() => {
clearDownloadColumnDrag(session);
if (columnDragSessionRef.current === session) columnDragSessionRef.current = null;
columnDragSettleTimerRef.current = null;
}, 220);
},
onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y })
};
@@ -5616,15 +5679,16 @@ export function App(): ReactElement {
/>
<nav aria-label="Anwendungsmenü" className="md-application-menu-tree">
<div className="menu-bar-item">
<button
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
<button
aria-expanded={openMenu === "datei"}
aria-haspopup="menu"
className={`menu-bar-trigger${openMenu === "datei" ? " open" : ""}`}
onClick={() => setOpenMenu(openMenu === "datei" ? null : "datei")}
onMouseEnter={() => { if (openMenu && openMenu !== "datei") { setOpenMenu("datei"); setOpenSubmenu(null); } }}
>
Datei
</button>
{openMenu === "datei" && (
<div className="menu-dropdown">
<div aria-hidden={openMenu !== "datei"} className={`menu-dropdown${openMenu === "datei" ? " is-open" : ""}`}>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("collector"); }}>
<span>Text mit Links analysieren</span>
<span className="shortcut">Strg+L</span>
@@ -5662,19 +5726,19 @@ export function App(): ReactElement {
<span>Beenden</span>
<span className="shortcut">Strg+Q</span>
</button>
</div>
)}
</div>
</div>
<div className="menu-bar-item">
<button
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
<button
aria-expanded={openMenu === "einstellungen"}
aria-haspopup="menu"
className={`menu-bar-trigger${openMenu === "einstellungen" ? " open" : ""}`}
onClick={() => setOpenMenu(openMenu === "einstellungen" ? null : "einstellungen")}
onMouseEnter={() => { if (openMenu && openMenu !== "einstellungen") { setOpenMenu("einstellungen"); setOpenSubmenu(null); } }}
>
Einstellungen
</button>
{openMenu === "einstellungen" && (
<div className="menu-dropdown">
<div aria-hidden={openMenu !== "einstellungen"} className={`menu-dropdown${openMenu === "einstellungen" ? " is-open" : ""}`}>
<button className="menu-dropdown-item" onClick={() => { closeMenus(); setTab("settings"); }}>
<span>Einstellungen</span>
<span className="shortcut">Strg+P</span>
@@ -5772,19 +5836,19 @@ export function App(): ReactElement {
</div>
<span className="menu-speed-unit">MB/s</span>
</div>
</div>
)}
</div>
</div>
<div className="menu-bar-item">
<button
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
<button
aria-expanded={openMenu === "hilfe"}
aria-haspopup="menu"
className={`menu-bar-trigger${openMenu === "hilfe" ? " open" : ""}`}
onClick={() => setOpenMenu(openMenu === "hilfe" ? null : "hilfe")}
onMouseEnter={() => { if (openMenu && openMenu !== "hilfe") { setOpenMenu("hilfe"); setOpenSubmenu(null); } }}
>
Hilfe
</button>
{openMenu === "hilfe" && (
<div className="menu-dropdown">
<div aria-hidden={openMenu !== "hilfe"} className={`menu-dropdown${openMenu === "hilfe" ? " is-open" : ""}`}>
<div
className="menu-submenu"
onMouseEnter={() => setOpenSubmenu("hilfe-log")}
@@ -5836,8 +5900,7 @@ export function App(): ReactElement {
<button className="menu-dropdown-item" onClick={() => { closeMenus(); void onCheckUpdates(); }}>
<span>Suche Aktualisierungen</span>
</button>
</div>
)}
</div>
</div>
</nav>
<div className="md-avatar-anchor">
+8
View File
@@ -1,4 +1,5 @@
import type { AudioStripSummary, DebridProvider } from "../shared/types";
import { hosterIconSources } from "./hoster-icons";
export const providerLabels: Record<DebridProvider, string> = {
realdebrid: "Real-Debrid",
@@ -48,6 +49,13 @@ export function extractHoster(url: string): string {
}
}
export function formatHosterLabel(hoster: string): { compact: string; title: string; iconSrc?: string } {
const normalized = hoster.trim().toLowerCase();
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
return { compact: hoster, title: hoster };
}
export function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } {
const parts: string[] = [];
const ok = summary.remuxed + summary.keptSingle;
+4
View File
@@ -0,0 +1,4 @@
export const hosterIconSources: Readonly<Record<string, string>> = {
rapidgator: "data:image/x-icon;base64,AAABAAIAEBAAAAEAGABoAwAAJgAAACAgAAABAAgAqAgAAI4DAAAoAAAAEAAAACAAAAABABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHVV8LEFQNTU2NTU2NTU2NTU2NTU2NTU2NTU2NTU2MDtDIk9vAAAAAAAAAAAALj5KKFaKHHXZFoP9FoP9FoP9FoP9FoP9FoP9FoP9FoP9GXzrJlqUMzpCmsDaHFd/KFiNGIX/GIX/GIX/GIX/GIX/GIX/LUpqLUpqLUpqIWq7GIX/GIX/Jl+gLT9NLEFPH3jcGob/Gob/Gob/Gob/Gob/Gob/NDQ0NDQ0NDQ0NDQ0JGWuGob/Gob/NTU2MTpAHoLyHIf/HIf/HIf/HIf/HIf/HIf/HIf/HIf/InHJNDQ0NDQ0IXbWHIf/NTU2MTpAIILyHoj/Hoj/Hoj/Hoj/IX3kLVB4NDQ0NDQ0JHLJLkpqNDQ0JHLJHoj/NTU2MTpAIYPyIIn/IIn/IIn/IYPxMT9PNDQ0MEVdL0tqJHjWL0tqNDQ0JXLJIIn/NTU2MTpAI4TyL0tqNDQ0J3PJKmKgNDQ0LlF4Ior/Ior/Ior/L0tqNDQ0J3PJIor/NTU2MTpAJYXyMEtqNDQ0KHTJK2KgNDQ0LV2TJIv/JIv/JIv/MEtqNDQ0KHTJJIv/NTU2MTpAKIbyMUtqNDQ0KnXJKnrWNDQ0NDQ0Ll2TLWOgLWOgMkBPNDQ0KnXJJ4z/NTU2MTpAKojyMUxqNDQ0LHbJKY7/LWquMzpCNDQ0NDQ0NDQ0NDQ0NDQ0LHbJKY7/NTU2MTpALInyMkxqNDQ0MFiFK4//K4//LInxLXfJLXfJLXfJLXfJLXfJLInxK4//NTU2Lz1HLobpL3G7NDQ0NDQ0M0ZdMk1qMk1qMGuuLZD/LZD/LZD/LZD/LZD/LZD/NTU2IFFzMl+TLpD+MGutNDQ0NDQ0NDQ0NDQ0MWWgLpD+LpD+LpD+LpD+LpD+MW60MTpAAAAANDc6MWKYLofrLoHfMHG6MHG6MHG6L3zTLofrLofrLofrLofrMWywM0hgFGKYAAAAo8ffKUZbMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpAMTpALz1HE2OZAAAAwAMAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAgAEAACgAAAAgAAAAQAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1NTYAo8ffAClGWwAxOkAALz1HABNjmQAAfdIACnC1ADQ3OgAxYpgALofrAC6B3wAwcboAL3zTADFssAAzSGAAFGKYACBRcwAyX5MALpD+ADBrrQA0NDQAMWWgADFutAAuhukAL3G7ADNGXQAyTWoAMGuuAC2Q/wA1NTYALInyADJMagAwWIUAK4//ACyJ8QAtd8kAKojyADFMagAsdskAKY7/AC1qrgAzOkIAKIbyADFLagAqdckAKnrWAC5dkwAtY6AAMkBPACeM/wAlhfIAMEtqACh0yQArYqAALV2TACSL/wAjhPIAL0tqACdzyQAqYqAALlF4ACKK/wAhg/IAIIn/ACGD8QAxP08AMEVdACR41gAlcskAIILyAB6I/wAhfeQALVB4ACRyyQAuSmoAHoLyAByH/wAicckAIXbWACxBTwAfeNwAGob/ACRlrgAcV38AKFiNABiF/wAtSmoAIWq7ACZfoAAtP00AB3S+AC4+SgAoVooAHHXZABaD/QAZfOsAJlqUAJrA2gAFdsIAHVV8ACxBUAAwO0MAIk9vAAtusQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpZGUeHh4eHh4eHmZnaWlpaWlpaWlpaWlpaWlpaWlpaVxdXl9fX19fX19fYGEqYmlpaWlpaWlpaWlpaWlpaWlUVUBAQEBAQFdXV1hWVllaaWlpaWlpaWlpaWlpaWlpaVBRQEBAQEBAFRUVFVNSUh5paWlpaWlpaWlpaWlpaWlpBEZAQEBAQEBAQEoVFU84HmlpaWlpaWlpaWlpaWlpaWkERkBAQEBISRUVSjQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlQEBAQUIVQzpENBUnOB5paWlpaWlpaWlpaWlpaWlpBCUgFSc2FT04ODg0FSc4HmlpaWlpaWlpaWlpaWlpaWkEJSAVJzYVNzg4ODQVJzgeaWlpaWlpaWlpaWlpaWlpaQQlIBUnLhUVLzAwMRUnEx5paWlpaWlpaWlpaWlpaWlpBCUgFSciKSoVFRUVFScTHmlpaWlpaWlpaWlpaWlpaWkEGCAVISIiIyQkJCQkIxMeaWlpaWlpaWlpaWlpaWlpaQQYGRUVGhsbHBMTExMTEx5paWlpaWlpaWlpaWlpaWlpERITFBUVFRUWExMTExMXA2lpaWlpaWlpaWlpaWlpaWlpCAkKCwwMDA0KCgoKDg8QaWlpaWlpaWlpaWlpaWlpaWkBAgMDAwMDAwMDAwMDBWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaf//////////////////////wAP//4AA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//8AAP//AAD//wAA//+AAP//gAH/////////////////////////////////////////////////////////////////",
ddownload: "./provider-icons/ddownload.ico"
};
+569
View File
@@ -0,0 +1,569 @@
import type { AppLanguage } from "../shared/types";
const pairs = [
["Downloads", "Downloads"], ["Linksammler", "Link collector"], ["Einstellungen", "Settings"], ["Verlauf", "History"], ["Statistiken", "Statistics"],
["Datei", "File"], ["Hilfe", "Help"], ["Kontomenü", "Account menu"], ["Allgemein", "General"], ["Accounts", "Accounts"],
["Hauptnavigation", "Main navigation"], ["Globale Aktionen", "Global actions"], ["Anwendungsmenü", "Application menu"], ["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"],
["Aktuelle Download-Geschwindigkeit (geglättet)", "Current download speed (smoothed)"], ["Einstellungsbereich", "Settings area"],
["Entpacken", "Extraction"], ["Geschwindigkeit", "Speed"], ["Bereinigung", "Cleanup"], ["Updates", "Updates"],
["Einstellungen speichern", "Save settings"], ["Gespeichert", "Saved"], ["Ungespeicherte Änderungen", "Unsaved changes"], ["Wird gespeichert…", "Saving…"], ["Speichern fehlgeschlagen", "Save failed"],
["Sprache", "Language"], ["Speicherort", "Storage location"], ["Download-Verhalten", "Download behavior"], ["Oberfläche und Bedienung", "Interface and controls"], ["Discord-Benachrichtigungen", "Discord notifications"],
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Dauerhaft", "Permanent"],
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
["Melden, wenn ein Paket fertig ist", "Notify when a package completes"], ["Melden, wenn ein Paket fehlschlägt", "Notify when a package fails"], ["Melden, wenn alles fertig ist", "Notify when everything completes"],
["Quelle und Zeitpunkt der Update-Prüfung.", "Update source and check timing."], ["Aktualisierung", "Update"], ["Beim Start nach Updates suchen", "Check for updates on startup"], ["Update-Quelle", "Update source"],
["Jetzt nach einer neuen Version suchen", "Check for a new version now"], ["Nach Updates suchen", "Check for updates"], ["Quelle im Format Benutzer/Repository.", "Source in owner/repository format."],
["Update verfügbar", "Update available"], ["Eine neue Version ist bereit. Klicke hier, um sie zu installieren.", "A new version is ready. Click here to install it."], ["Update installieren", "Install update"],
["Später", "Later"], ["Jetzt aktualisieren", "Update now"], ["Changelog anzeigen", "Show changelog"], ["Update wird vorbereitet...", "Preparing update..."], ["Update-Fortschritt", "Update progress"],
["Pakete", "Packages"], ["Dateien", "Files"], ["Alle", "All"], ["Aktiv", "Active"], ["Wartend", "Queued"], ["Pausiert", "Paused"], ["Fertig", "Completed"], ["Fehler", "Errors"],
["Downloadansicht", "Download view"], ["Downloadfilter", "Download filters"], ["Service filtern", "Filter service"], ["Downloads durchsuchen", "Search downloads"], ["Downloadstatus", "Download status"],
["Seitenleiste einklappen", "Collapse sidebar"], ["Seitenleiste ausklappen", "Expand sidebar"], ["Informationen", "Information"], ["Alle sichtbaren Downloads auswählen", "Select all visible downloads"],
["Links hinzufügen", "Add links"], ["Start", "Start"], ["Pause", "Pause"], ["Stop", "Stop"], ["Zeitplan", "Schedule"], ["Nach oben", "Move up"], ["Nach unten", "Move down"],
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"],
["Ablauf, Ziel, Tonspur, Ablageform und Leistung.", "Workflow, destination, audio track, layout and performance."], ["Ziel und Ablauf", "Destination and workflow"], ["Entpacken nach", "Extract to"],
["Zielordner für entpackte Dateien.", "Destination folder for extracted files."], ["Benötigt ffmpeg.", "Requires ffmpeg."],
["Wählen", "Choose"], ["Automatisch entpacken", "Extract automatically"], ["Bereits Entpacktes überspringen", "Skip already extracted files"], ["Entpackte Einträge ausblenden", "Hide extracted entries"],
["Entpacken auch ohne laufende Sitzung", "Extract without an active session"], ["Deutsche Tonspur", "German audio track"], ["Nur deutsche Tonspur behalten", "Keep German audio track only"], ["Welche Tonspur behalten", "Audio track to keep"],
["Ablageform", "Output layout"], ["Automatisch umbenennen", "Rename automatically"], ["In Paket-Unterordner ablegen", "Store in package subfolder"], ["Videos in Sammelordner verschieben", "Move videos to library folder"],
["Video-Sammelordner", "Video library folder"], ["Leistung", "Performance"], ["Hybrid-Entpacken", "Hybrid extraction"], ["Gleichzeitige Entpackungen", "Concurrent extractions"], ["CPU-Priorität beim Entpacken", "CPU priority during extraction"],
["Passwörter", "Passwords"], ["Passwortliste für Archive", "Archive password list"], ["Ein Passwort pro Zeile", "One password per line"], ["Hoch (80% CPU)", "High (80% CPU)"], ["Mittel (50% CPU)", "Medium (50% CPU)"], ["Niedrig (25% CPU)", "Low (25% CPU)"],
["Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.", "Speed, reconnection and scheduled bandwidth rules."], ["Tempo-Begrenzung", "Speed limit"], ["Geschwindigkeit begrenzen", "Limit speed"],
["Höchstgeschwindigkeit (MB/s)", "Maximum speed (MB/s)"], ["Limit gilt für", "Limit applies to"], ["Verbindung", "Connection"], ["Automatisch neu verbinden", "Reconnect automatically"], ["Wartezeit vor neuem Versuch (Sek.)", "Wait before retry (sec.)"],
["Von (Stunde)", "From (hour)"], ["Bis (Stunde)", "To (hour)"], ["Limit (MB/s)", "Limit (MB/s)"], ["Zeitregel aktiviert", "Schedule rule enabled"], ["Zeitregel entfernen", "Remove schedule rule"],
["Bandbreitenplanung", "Bandwidth schedule"], ["Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.", "Each rule sets a separate limit for a time window."], ["Weitere Zeitregel", "Additional schedule rule"], ["Zeitregel hinzufügen", "Add schedule rule"],
["Integritätsprüfung und Aufräumen nach Downloads und Entpacken.", "Integrity checks and cleanup after downloading and extraction."], ["Prüfung", "Verification"], ["Dateien auf Fehler prüfen", "Check files for errors"],
["Nach dem Entpacken", "After extraction"], ["Link-Dateien danach entfernen", "Remove link files afterward"], ["Vorschau-Dateien danach entfernen", "Remove sample files afterward"], ["Archive nach dem Entpacken", "Archives after extraction"],
["Fertige Downloads und Konflikte", "Completed downloads and conflicts"], ["Fertige Downloads aus der Liste", "Completed downloads in the list"], ["Bei gleichnamigen Dateien", "When files have the same name"],
["Hinzufügen", "Add"], ["Aktualisieren", "Refresh"], [" Hinzufügen", " Add"], [" Entfernen", " Remove"], ["↻ Aktualisieren", "↻ Refresh"], ["Provider-Reihenfolge", "Provider order"], ["Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.", "Choose the order in which available providers are used."],
["Automatischer Fallback", "Automatic fallback"], ["Zugangsdaten lokal speichern", "Store credentials locally"], ["Hoster-Routing", "Hoster routing"], ["Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.", "Custom assignments override the default order for each hoster."],
["Rotations-Verlauf", "Rotation history"], ["Sammlung", "Collection"], ["URL oder Rohzeile", "URL or raw line"], ["Zeile", "Line"], ["Paket / Datei", "Package / file"], ["Größe", "Size"], ["Gestartet", "Started"], ["Beendet", "Finished"],
["Alle Einträge", "All entries"], ["Heute", "Today"], ["Letzte 7 Tage", "Last 7 days"], ["Älter", "Older"], ["Gelöscht", "Deleted"], ["Fehlgeschlagen", "Failed"],
["Dauer", "Duration"], ["Durchschnitt", "Average"], ["Zielordner", "Destination folder"], ["Verlaufsfilter", "History filters"], ["Verlauf leeren", "Clear history"], ["Verlaufsaktionen", "History actions"],
["Einträge", "Entries"], ["Erneut hinzufügen", "Add again"], ["Im Ordner zeigen", "Show in folder"], ["Auswahl löschen", "Clear selection"], ["Verlauf durchsuchen", "Search history"], ["Name, Pfad, Hoster oder Provider", "Name, path, hoster or provider"],
["Verlaufstabelle", "History table"], ["Verlauf wird geladen", "Loading history"], ["Die gespeicherten Einträge werden geladen.", "Saved entries are being loaded."], ["Noch kein Verlauf", "No history yet"], ["Keine passenden Einträge", "No matching entries"],
["Abgeschlossene und gelöschte Pakete erscheinen hier.", "Completed and deleted packages appear here."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Öffne die Ansicht erneut, um es noch einmal zu versuchen.", "Open the view again to retry."],
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"],
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."],
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
["Bandbreitenverlauf", "Bandwidth history"], ["Provider", "Provider"], ["Daten", "Data"], ["Ergebnisse", "Results"],
["Nie", "Never"], ["Sofort", "Immediately"], ["Beim App-Start", "On app startup"], ["Sobald Paket fertig ist", "When package completes"], ["Überschreiben", "Overwrite"], ["Überspringen", "Skip"], ["Nachfragen", "Ask"],
["Abbrechen", "Cancel"], ["Speichern", "Save"], ["Schließen", "Close"], ["Löschen", "Delete"], ["Suchen", "Search"], ["Zurücksetzen", "Reset"], ["Testen", "Test"], ["Öffnen", "Open"],
["Noch keine Downloads", "No downloads yet"], ["Füge Links hinzu, um den ersten Download zu starten.", "Add links to start the first download."], ["Keine passenden Downloads", "No matching downloads"], ["Alle anzeigen", "Show all"],
["Neue Sammlung", "New collection"], ["Linksammler-Aktionen", "Link collector actions"], ["Links erfassen", "Capture links"], ["DLC importieren", "Import DLC"], ["Datei importieren", "Import file"],
["Sammlung verarbeiten", "Process collection"], ["Queue exportieren", "Export queue"], ["An Downloads übergeben", "Send to downloads"], ["Auswahl entfernen", "Remove selection"], ["Gesammelte Links", "Collected links"],
["Auswahl", "Selection"], ["Links werden verarbeitet", "Processing links"], ["Die laufende Aktion wird abgeschlossen.", "The current action is being completed."], ["Die lokale Sammlung bleibt unverändert.", "The local collection remains unchanged."],
["Passe die Suche an oder lösche den Filter.", "Adjust the search or clear the filter."], ["Füge Links hinzu oder importiere eine vorhandene Liste.", "Add links or import an existing list."], ["Keine passenden Links", "No matching links"], ["Noch keine Links", "No links yet"],
["Link auswählen", "Select link"], ["Lokal", "Local"], ["Übernehmen", "Apply"], ["Eine URL oder Rohzeile pro Zeile", "One URL or raw line per line"],
["Die Accountdaten werden aktualisiert.", "Account data is being updated."], ["Accounts werden geladen", "Loading accounts"], ["Die gespeicherten Accounts bleiben unverändert.", "Saved accounts remain unchanged."],
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"],
["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"],
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
["Der ausgewählte Mega-Debrid-Account wurde nicht gefunden.", "The selected Mega-Debrid account was not found."], ["Der ausgewählte Debrid-Link-Key wurde nicht gefunden.", "The selected Debrid-Link key was not found."],
["Das Tageslimit muss eine positive Zahl oder 0 sein.", "The daily limit must be a positive number or 0."], ["Login und Passwort werden benötigt.", "Login and password are required."],
["Der Login darf keinen Doppelpunkt oder Zeilenumbruch enthalten.", "The login must not contain a colon or line break."], ["Das Passwort darf keinen Zeilenumbruch enthalten.", "The password must not contain a line break."],
["Dieser Mega-Debrid-Login ist bereits vorhanden.", "This Mega-Debrid login already exists."], ["Der API-Key wird benötigt.", "The API key is required."], ["Beim Bearbeiten ist genau ein API-Key erlaubt.", "Exactly one API key is allowed when editing."],
["Dieser Debrid-Link-Key ist bereits vorhanden.", "This Debrid-Link key already exists."], ["Der Zugangstoken wird benötigt.", "The access token is required."], ["Die Prüfung hat keinen Account zurückgegeben.", "The check returned no account."],
["Die Prüfung hat mehr als den ausgewählten Account zurückgegeben.", "The check returned more than the selected account."], ["Die Prüfung hat den falschen Account zurückgegeben.", "The check returned the wrong account."], ["Zugangsdaten ungültig", "Invalid credentials"],
["Für diesen Zeitraum werden noch keine historischen Daten gespeichert.", "Historical data is not stored for this time range yet."], ["Keine abgeschlossenen oder fehlgeschlagenen Ergebnisse", "No completed or failed results"],
["Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.", "Today's data comes from the local provider usage counters for the current calendar day."],
["Provider-Nutzung heute", "Provider usage today"], ["Dateianzahlen werden nicht tagesweise gespeichert", "File counts are not stored by day"], ["Ergebnisse werden nicht tagesweise gespeichert", "Results are not stored by day"],
["Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert", "Average speed is not stored by day"], ["Fehler werden nicht tagesweise gespeichert", "Errors are not stored by day"],
["Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.", "Totals come from permanently stored counters. Results and speeds are not stored historically."],
["Gesamtzähler", "Total counter"], ["Ergebnisse werden nicht dauerhaft gespeichert", "Results are not stored permanently"], ["Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert", "Average speed is not stored permanently"],
["Fehler werden nicht dauerhaft gespeichert", "Errors are not stored permanently"], ["Aktuelle Queue", "Current queue"], ["Letzter beendeter Lauf", "Last completed run"],
["Während des laufenden Durchgangs nicht als Gesamtwert verfügbar", "Unavailable as a total while the run is active"], ["Kein beendeter Lauf mit Ergebnissen verfügbar", "No completed run with results available"],
["Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt.", "Session counters and results from the last completed run are shown."], ["Nicht verfügbar", "Unavailable"],
["Heute wurden noch keine Providerbytes erfasst.", "No provider bytes have been recorded today."], ["Noch keine gespeicherten Providerbytes vorhanden.", "No stored provider bytes available yet."],
["In der aktuellen Queue sind noch keine Providerwerte vorhanden.", "No provider values are available in the current queue yet."], ["Statistik-Zeitraum", "Statistics time range"], ["Statistik-Dashboard", "Statistics dashboard"],
["Statistiken zurücksetzen", "Reset statistics"], ["Erfolgsquote", "Success rate"], ["Live aus der aktuellen Renderer-Sitzung", "Live from the current renderer session"],
["Kontextmenü", "Context menu"], ["Die Oberfläche hat einen Fehler ausgelöst", "The interface encountered an error"],
["Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.", "The interface was stopped to prevent data loss. Downloads running in the background are not affected. The error was written to the log."],
["Oberfläche neu laden", "Reload interface"], ["Unbekannter Fehler", "Unknown error"],
["Direkter Zugriff über API-Token.", "Direct access via API token."], ["Login über Browserfenster statt Token.", "Login through a browser window instead of a token."],
["Login:Passwort-Paare für Mega-Debrid (API). Mehrere Accounts zeilenweise für Multi-Account.", "Login:password pairs for Mega-Debrid (API). Enter multiple accounts on separate lines for multi-account."],
["Login:Passwort-Paare für Mega-Debrid (Web). Mehrere Accounts zeilenweise für Multi-Account.", "Login:password pairs for Mega-Debrid (Web). Enter multiple accounts on separate lines for multi-account."],
["Cookie-Import aus dem Browser statt API-Token.", "Import cookies from the browser instead of using an API token."], ["Direkter Zugriff über API-Key.", "Direct access via API key."],
["Login über Browserfenster für reCAPTCHA.", "Login through a browser window for reCAPTCHA."], ["Direkter Login für ddownload.com und ddl.to.", "Direct login for ddownload.com and ddl.to."],
["API-Key für 1fichier.com.", "API key for 1fichier.com."], ["API-Key(s) für debrid-link.com. Mehrere Keys zeilenweise für Multi-Account.", "API key(s) for debrid-link.com. Enter multiple keys on separate lines for multi-account."],
["Login für linksnappy.com mit Benutzername und Passwort.", "Login for linksnappy.com with username and password."], ["Login:Passwort (API)", "Login:password (API)"], ["Login:Passwort (Web)", "Login:password (Web)"],
["Login + Passwort", "Login + password"], ["Login gespeichert", "Login saved"], ["API-Key gespeichert", "API key saved"], ["Zugang gespeichert", "Access saved"], ["Nicht hinterlegt", "Not stored"],
["Bitte zuerst einen Account-Typ auswählen.", "Select an account type first."], ["Nicht angegeben", "Not specified"], ["Nur per API-Key sichtbar", "Visible only via API key"],
["abgebrochen (fataler Fehler)", "cancelled (fatal error)"], ["übersprungen (bis zum Tagesreset gesperrt)", "skipped (blocked until daily reset)"], ["übersprungen (Cooldown aktiv)", "skipped (cooldown active)"],
["übersprungen (deaktiviert)", "skipped (disabled)"], ["übersprungen (Tageslimit erreicht)", "skipped (daily limit reached)"], ["übersprungen (Host-Cooldown)", "skipped (host cooldown)"],
["Provider-weiter Fehler, restliche Keys übersprungen", "Provider-wide error, remaining keys skipped"], ["Netzwerk-Kaskade, restliche Keys übersprungen", "Network cascade, remaining keys skipped"],
["Key ist manuell deaktiviert.", "Key is manually disabled."], ["Lokales Tageslimit erreicht.", "Local daily limit reached."], ["Live-Status wird geladen.", "Loading live status."],
["Der Hoster ist laut Debrid-Link aktuell offline.", "The hoster is currently offline according to Debrid-Link."], ["Key ist nutzbar.", "Key is usable."], ["Download starten für Statistiken", "Start a download for statistics"],
["Download fertig | Prüfe Integrität...", "Download complete | Checking integrity..."], ["Starte Installer...", "Starting installer..."], ["Installer gestartet", "Installer started"],
["Verlauf konnte nicht geladen werden", "History could not be loaded"], ["Debrid-Link ist nicht konfiguriert", "Debrid-Link is not configured"], ["Entpacken -", "Extraction -"],
["Nur API aktiv. Kein Web-Fallback.", "API only. No web fallback."], ["Nur Web aktiv. Kein API-Fallback.", "Web only. No API fallback."], ["Login kann bei Bedarf direkt aus der Liste geöffnet werden.", "The login can be opened directly from the list when needed."],
["Cookie-Import lässt sich direkt aus der Liste erneut starten.", "Cookie import can be started again directly from the list."], ["Lade Status", "Load status"], ["Rapidgator-Status wird aktualisiert.", "RapidGator status is being updated."],
["Rapidgator-Status kann direkt aus der Liste geladen werden.", "RapidGator status can be loaded directly from the list."], ["Status basiert auf den zuletzt gespeicherten AllDebrid-Daten.", "Status is based on the last saved AllDebrid data."],
["Update wird vorbereitet", "Preparing update"], ["Stilles Update gestartet - App wird neu gestartet", "Silent update started - the app will restart"], ["Einstellungen gespeichert", "Settings saved"],
["Real-Debrid Login-Fenster geöffnet", "Real-Debrid login window opened"], ["AllDebrid Login-Fenster geöffnet", "AllDebrid login window opened"], ["Keine Cookie-Datei ausgewählt", "No cookie file selected"],
["Keine Mega-Debrid-/Debrid-Link-Accounts zum Prüfen konfiguriert.", "No Mega-Debrid or Debrid-Link accounts configured for checking."], ["Account aktiviert", "Account enabled"], ["Account deaktiviert", "Account disabled"],
["Account entfernen", "Remove account"], ["Account entfernt", "Account removed"], ["Key entfernen", "Remove key"], ["Key entfernt", "Key removed"], ["Accounts aktiviert", "Accounts enabled"], ["Accounts deaktiviert", "Accounts disabled"],
["Für diesen Account ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this account."], ["Keine gespeicherten Links vorhanden", "No saved links available"], ["Keine Links hinzugefügt", "No links added"],
["Fehler beim Hinzufügen", "Error while adding"], ["Verlaufseintrag entfernen", "Remove history entry"], ["Verlaufseinträge entfernen", "Remove history entries"], ["Diesen Eintrag aus dem Verlauf entfernen?", "Remove this entry from history?"],
["Einige Verlaufseinträge konnten nicht entfernt werden", "Some history entries could not be removed"], ["Wirklich alle Einträge aus dem Verlauf entfernen?", "Remove all entries from history?"], ["Verlauf geleert", "History cleared"],
["Verlauf konnte nicht geleert werden", "History could not be cleared"], ["Zielordner geöffnet", "Destination folder opened"], ["Verlaufseintrag wurde nicht gefunden", "History entry was not found"],
["Der gespeicherte Zielordner ist ungültig", "The saved destination folder is invalid"], ["Der gespeicherte Zielordner existiert nicht mehr", "The saved destination folder no longer exists"],
["Das gespeicherte Ziel ist kein Ordner", "The saved destination is not a folder"], ["Zielordner konnte nicht geöffnet werden", "Destination folder could not be opened"], ["Bitte zuerst mindestens einen Hoster-Account eintragen", "Add at least one hoster account first"],
["Start abgebrochen", "Start cancelled"], ["Keine gültigen Links gefunden", "No valid links found"], ["Keine gültigen Links in den DLC-Dateien gefunden", "No valid links found in the DLC files"],
["Keine gültigen Links in den Import-Dateien gefunden", "No valid links found in the import files"], ["Links per Drag-and-Drop eingefügt", "Links added by drag and drop"], ["Queue exportiert", "Queue exported"],
["Keine gültigen Links in der Datei gefunden", "No valid links found in the file"], ["Sicherung exportiert", "Backup exported"], ["Online-Schlüssel erstellt", "Online key created"],
["Online-Sicherung konnte nicht erstellt werden.", "Online backup could not be created."], ["Online-Sicherung konnte nicht geladen werden. Schlüssel prüfen und erneut versuchen.", "Online backup could not be loaded. Check the key and try again."],
["Online-Schlüssel kopiert", "Online key copied"], ["Schlüssel konnte nicht kopiert werden", "Key could not be copied"], ["Support-Bundle exportiert", "Support bundle exported"],
["Support-Trace für 2 Stunden aktiviert", "Support trace enabled for 2 hours"], ["Support-Trace deaktiviert", "Support trace disabled"], ["Keine akuten Warnungen", "No current warnings"],
["Remote-fähig konfiguriert", "Configured for remote access"], ["Debug-Setup prüfen", "Check debug setup"], ["Letzte Fehler", "Recent errors"], ["Keine Fehler oder Warnungen seit dem App-Start aufgezeichnet.", "No errors or warnings recorded since the app started."],
["In Zwischenablage kopieren", "Copy to clipboard"], ["Einträge anzeigen", "Show entries"], ["Fehlerliste kopiert", "Error list copied"], ["Debug-Token rotieren", "Rotate debug token"],
["Das aktuelle Debug-Token wird ersetzt. Externe Debug-Links mit altem Token funktionieren danach nicht mehr.", "The current debug token will be replaced. External debug links using the old token will stop working."],
["Token rotieren", "Rotate token"], ["Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", "Network mode requires at least one IP address or CIDR in the allowlist"],
["Ferndiagnose aktiv", "Remote diagnostics active"], ["Ferndiagnose konfiguriert", "Remote diagnostics configured"], ["Ferndiagnose deaktiviert", "Remote diagnostics disabled"],
["Neues Token - alter Verbindungscode ist ungueltig", "New token - old connection code is invalid"], ["Verbindungscode kopiert", "Connection code copied"], ["Kopieren fehlgeschlagen", "Copy failed"],
["Queue löschen", "Delete queue"], ["Wirklich alle Einträge aus der Queue löschen?", "Delete all entries from the queue?"], ["Alles löschen", "Delete all"], ["Session-Statistik zurückgesetzt", "Session statistics reset"],
["Gesamt-Downloadstatistik zurückgesetzt", "Total download statistics reset"], ["Hoster-Domain eingeben:", "Enter hoster domain:"], ["Test-Nachricht gesendet", "Test message sent"], ["Test fehlgeschlagen", "Test failed"],
["Login / E-Mail", "Login / email"], ["Token / API-Key", "Token / API key"], ["Tageslimit (GB, optional)", "Daily limit (GB, optional)"], ["Kein Limit", "No limit"],
["Der Zähler wird täglich um 00:00 Uhr zurückgesetzt.", "The counter resets every day at 00:00."], ["Account erfolgreich geprüft", "Account checked successfully"], ["Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", "Direct status checking is not available for this service."],
["Paket- und Linkstatus werden laufend aktualisiert. Auswahl, Reihenfolge und aktive Filter bleiben beim Ansichtswechsel erhalten.", "Package and link status are updated continuously. Selection, order and active filters remain in place when switching views."],
["Rohzeilen bleiben lokal in der gewählten Sammlung, bis sie über „An Downloads übergeben“ an die Queue gesendet werden. Strg+L öffnet den Linksammler, Strg+O lädt DLC-Dateien.", "Raw lines remain local in the selected collection until they are sent to the queue using “Send to downloads”. Ctrl+L opens the link collector, Ctrl+O loads DLC files."],
["Abgeschlossene und gelöschte Pakete bleiben hier durchsuchbar. Details zeigen Zielordner, Provider und gespeicherte Linkadressen.", "Completed and deleted packages remain searchable here. Details show the destination folder, provider and saved link addresses."],
["Für sieben und 30 Tage bleiben Kennzahlen leer, solange keine historischen Buckets gespeichert werden.", "Metrics remain empty for seven and 30 days while no historical buckets are stored."],
["Text mit Links analysieren", "Analyze text containing links"], ["Online-Schlüssel exportieren", "Export online key"], ["Online-Schlüssel importieren", "Import online key"], ["Logs öffnen", "Open logs"],
["Support-Bundle exportieren", "Export support bundle"], ["Support-Trace deaktivieren", "Disable support trace"], ["Support-Trace aktivieren", "Enable support trace"], ["Letzte Fehler anzeigen", "Show recent errors"],
["Einträge:", "Entries:"], ["Ausgewählt:", "Selected:"], ["Online-Schlüssel", "Online key"],
["Dieser Schlüssel stellt deine Einstellungen inklusive gespeicherter Zugangsdaten wieder her. Bewahre ihn wie ein Passwort auf.", "This key restores your settings, including saved credentials. Keep it as secure as a password."],
["Füge den vollständigen MDD2-Schlüssel ein. Die aktuellen Einstellungen werden durch die gespeicherte Version ersetzt.", "Paste the complete MDD2 key. The current settings will be replaced by the saved version."],
["Online-Sicherung wird verschlüsselt und gespeichert …", "Online backup is being encrypted and saved …"], ["Online-Sicherungsschlüssel", "Online backup key"], ["Online-Sicherungsschlüssel eingeben", "Enter online backup key"], ["Wird geladen …", "Loading …"],
["Ermöglicht einer vertrauenswürdigen Support-Stelle den geschützten Lesezugriff auf Status, Logs und Fehler. Der Verbindungscode enthält das Zugriffstoken und ist wie ein Passwort zu behandeln.", "Allows a trusted support contact protected read access to status, logs and errors. The connection code contains the access token and must be treated like a password."],
["Oeffentliche Adresse (fuer den Verbindungscode)", "Public address (for the connection code)"], ["Allowlist - erlaubte IPs/CIDR (eine pro Zeile)", "Allowlist - permitted IPs/CIDRs (one per line)"],
["Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.", "Required in network mode. Only these source IPs may connect (in addition to the token). Loopback is always allowed."],
["Token neu (Code ungueltig machen)", "New token (invalidate code)"], ["Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.", "Contains the access token - treat it like a password. New token = old code becomes invalid immediately."],
["Möchtest Du wirklich diese Aufräumaktion(en) durchführen?", "Do you really want to perform these cleanup action(s)?"], ["Ausgewählte Links löschen", "Delete selected links"], ["Nicht mehr anzeigen", "Do not show again"],
["Paket bereits entpackt", "Package already extracted"], ["ist im Ziel bereits vorhanden.", "already exists at the destination."], ["Für alle weiteren Pakete dieselbe Auswahl verwenden", "Use the same selection for all remaining packages"],
["Entpacktes überspringen", "Skip extracted content"], ["Links, .dlc oder Export-Dateien hier ablegen", "Drop links, .dlc or export files here"], ["Account prüfen", "Check account"],
["Account aktivieren", "Enable account"], ["Account deaktivieren", "Disable account"], ["Ausgewählte Downloads starten", "Start selected downloads"], ["Alle Downloads starten", "Start all downloads"],
["Linkadressen anzeigen", "Show link addresses"], ["Paket exportieren", "Export package"], ["Log öffnen", "Open log"], ["Item-Log öffnen", "Open item log"], ["Jetzt entpacken", "Extract now"],
["API-Key Statistik", "API key statistics"], ["Status unbekannt", "Status unknown"], ["RG Links", "RG links"], ["Alle Namen kopiert", "All names copied"], ["Alle Namen kopieren", "Copy all names"],
["Alle Links kopiert", "All links copied"], ["Alle Links kopieren", "Copy all links"],
["nicht gesetzt", "not set"], ["Schätzwert", "Estimate"], ["ist verfügbar. Installierte Version:", "is available. Installed version:"],
["Real-Debrid Web-Login", "Real-Debrid web login"], ["Mega-Debrid Web-Login", "Mega-Debrid web login"], ["BestDebrid Web-Login", "BestDebrid web login"], ["AllDebrid Web-Login", "AllDebrid web login"],
["DDownload Login", "DDownload login"], ["Debrid-Link API", "Debrid-Link API"], ["LinkSnappy Web-Login", "LinkSnappy web login"],
["Tageslimit erreicht. Neue Links wechseln auf den nächsten Hoster.", "Daily limit reached. New links will switch to the next hoster."], ["Mega-Debrid: Bitte Login und Passwort eintragen.", "Mega-Debrid: Enter a login and password."],
["Dieser Mega-Debrid-Account ist bereits vorhanden.", "This Mega-Debrid account already exists."], ["Debrid-Link: Bitte genau einen API-Key eintragen.", "Debrid-Link: Enter exactly one API key."],
["Die Prüfung hat nicht genau den neuen Account bestätigt.", "The check did not confirm exactly the new account."], ["Nur lokal gebunden", "Bound locally only"], ["(nur lokal)", "(local only)"], ["Nur lokal", "Local only"],
["Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante.", "Binds only to 127.0.0.1. Remote access only through a tunnel (such as Tailscale/SSH) - the safest option."],
["Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen.", "Binds to 0.0.0.0. Reachable on the network and requires an allowlist. Use only on trusted networks or VPNs."],
["127.0.0.1 oder Tunnel-Adresse", "127.0.0.1 or tunnel address"], ["Server-IP oder DNS-Name", "Server IP or DNS name"], ["löschen ?", "delete?"], ["Link(s) verbleiben!", "link(s) remaining!"],
["Bei \"überspringen\" wird nur das erneute Entpacken übersprungen - offene Downloads bleiben in der Queue.", "With \"skip\", only repeated extraction is skipped - open downloads remain in the queue."],
["überschreiben", "overwrite"], ["überspringen", "skip"], ["Priorität >", "Priority >"], ["Keys · Heute:", "Keys · Today:"], ["· Rapidgator-Quota wird geladen (", "· Loading RapidGator quota ("],
["· API-Quota konnte nicht geladen werden", "· API quota could not be loaded"], ["Name kopiert", "Name copied"], ["Link kopiert", "Link copied"],
["Download-Ziel", "Download destination"], ["Support-Logs:", "Support logs:"], ["Session-Logs", "Session logs"], ["Paket-Logs", "Package logs"], ["Support-Bundle:", "Support bundle:"], ["Warnungen:", "Warnings:"],
["Web-Login", "Web login"], ["API-Token", "API token"], ["erfolgreich", "successful"], ["Unbekannt", "Unknown"], ["Abgebrochen", "Cancelled"], ["Passwort", "Password"],
["Sicherung", "Backup"], ["Session-Log", "Session log"], ["Remote-Support", "Remote support"], ["Kopieren", "Copy"], ["Verbindungscode", "Connection code"], ["deaktiviert", "disabled"]
] as const;
const deToEn = new Map<string, string>(pairs);
const enToDe = new Map<string, string>(pairs.map(([de, en]) => [en, de]));
const attributes = ["aria-label", "placeholder", "title"] as const;
const prefixedPairs = [
["Status: ", "Status: "], ["Debug-Server aktiv: ", "Debug server active: "], ["Runtime-Ordner: ", "Runtime folder: "], ["Token-Datei: ", "Token file: "], ["Support-Manifest: ", "Support manifest: "], ["Trace aktiv: ", "Trace active: "],
["Unbekannter Account-Typ: ", "Unknown account type: "], ["Update-Download: ", "Update download: "], ["Update: ", "Update: "], ["Warnungen: ", "Warnings: "], ["Debug-Token rotiert: ", "Debug token rotated: "],
["AllDebrid Status fehlgeschlagen: ", "AllDebrid status failed: "], ["Debrid-Link Quota fehlgeschlagen: ", "Debrid-Link quota failed: "], ["Snapshot konnte nicht geladen werden: ", "Snapshot could not be loaded: "],
["Update-Check fehlgeschlagen: ", "Update check failed: "], ["Update-Fehler: ", "Update error: "], ["Auto-Update fehlgeschlagen: ", "Automatic update failed: "],
["Einstellungen konnten nicht gespeichert werden: ", "Settings could not be saved: "], ["Real-Debrid Login fehlgeschlagen: ", "Real-Debrid login failed: "], ["AllDebrid Login fehlgeschlagen: ", "AllDebrid login failed: "],
["BestDebrid Cookie-Import fehlgeschlagen: ", "BestDebrid cookie import failed: "], ["Account-Check fehlgeschlagen: ", "Account check failed: "], ["Prüfung fehlgeschlagen: ", "Check failed: "],
["Account konnte nicht gespeichert werden: ", "Account could not be saved: "], ["Reset fehlgeschlagen: ", "Reset failed: "], ["Umschalten fehlgeschlagen: ", "Toggle failed: "],
["Entfernen fehlgeschlagen: ", "Removal failed: "], ["Account konnte nicht entfernt werden: ", "Account could not be removed: "], ["Accounts konnten nicht umgeschaltet werden: ", "Accounts could not be toggled: "],
["Fehler beim Hinzufügen: ", "Error while adding: "], ["Fehler beim DLC-Import: ", "DLC import error: "], ["Export fehlgeschlagen: ", "Export failed: "], ["Fehler bei Drag-and-Drop: ", "Drag-and-drop error: "],
["Import fehlgeschlagen: ", "Import failed: "], ["Fehler: ", "Error: "], ["Sortierung fehlgeschlagen: ", "Sorting failed: "], ["Umbenennen fehlgeschlagen: ", "Rename failed: "],
["Paket-Löschung fehlgeschlagen: ", "Package deletion failed: "], ["Paket-Umschalten fehlgeschlagen: ", "Package toggle failed: "], ["Sicherung fehlgeschlagen: ", "Backup failed: "],
["Sicherung laden fehlgeschlagen: ", "Loading backup failed: "], ["Support-Bundle fehlgeschlagen: ", "Support bundle failed: "], ["Support-Trace fehlgeschlagen: ", "Support trace failed: "],
["Debug-Setup-Check fehlgeschlagen: ", "Debug setup check failed: "], ["Fehler-Ansicht fehlgeschlagen: ", "Error view failed: "], ["Token-Rotation fehlgeschlagen: ", "Token rotation failed: "],
["Ferndiagnose-Status fehlgeschlagen: ", "Remote diagnostics status failed: "], ["Aktivieren fehlgeschlagen: ", "Enabling failed: "], ["Deaktivieren fehlgeschlagen: ", "Disabling failed: "],
["Session-Reset fehlgeschlagen: ", "Session reset failed: "], ["Download-Reset fehlgeschlagen: ", "Download reset failed: "]
] as const;
export function normalizeLanguage(value: unknown): AppLanguage {
return value === "de" ? "de" : "en";
}
function translateDynamic(value: string, language: AppLanguage): string {
for (const [german, english] of prefixedPairs) {
const source = language === "en" ? german : english;
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
}
if (language === "en") {
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
const pagination = value.match(/^(\d+\s*[-]\s*\d+) von (\d+)$/);
if (pagination) return `${pagination[1]} of ${pagination[2]}`;
const schedule = value.match(/^Zeitregel (\d+)$/);
if (schedule) return `Schedule rule ${schedule[1]}`;
const scheduled = value.match(/^Geplant: (.+)$/);
if (scheduled) return `Scheduled: ${scheduled[1].replace(/^Heute\b/, "Today")}`;
const cancelled = value.match(/^(\d+) abgebrochen$/);
if (cancelled) return `${cancelled[1]} cancelled`;
const labelledCount = value.match(/^(Einträge|Sichtbar|Ausgewählt): (\d+)$/);
if (labelledCount) return `${({ Einträge: "Entries", Sichtbar: "Visible", Ausgewählt: "Selected" } as const)[labelledCount[1] as "Einträge" | "Sichtbar" | "Ausgewählt"]}: ${labelledCount[2]}`;
const perPage = value.match(/^(\d+) pro Seite$/);
if (perPage) return `${perPage[1]} per page`;
const filter = value.match(/^(Alle|Aktiv|Wartend|Pausiert|Fertig|Fehler) (\d+)$/);
if (filter) return `${deToEn.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const remaining = value.match(/^(.+) von (.+) übrig$/);
if (remaining) return `${remaining[1]} of ${remaining[2]} remaining`;
const actionsFor = value.match(/^Aktionen für (.+)$/);
if (actionsFor) return `Actions for ${actionsFor[1]}`;
const assignment = value.match(/^(.+) Zuordnung entfernen$/);
if (assignment) return `Remove ${assignment[1]} assignment`;
const providerFor = value.match(/^Provider für (.+)$/);
if (providerFor) return `Provider for ${providerFor[1]}`;
const move = value.match(/^(.+) nach (oben|unten)$/);
if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`;
const audio = value.match(/^Tonspur: (.+)$/);
if (audio) return `Audio track: ${audio[1].replace(/ohne DE-Tag/g, "without DE tag").replace(/ffmpeg fehlt/g, "ffmpeg missing").replace(/(\d+) Fehler/g, "$1 errors")}`;
const result = value.match(/^(\d+\/\d+) fertig(.*)$/);
if (result) return `${result[1]} completed${result[2].replace(/(\d+) Fehler/g, "$1 errors").replace(/(\d+) abgebrochen/g, "$1 cancelled")}`;
const extracting = value.match(/^Entpacken (\d+%)$/);
if (extracting) return `Extracting ${extracting[1]}`;
const checkedUntil = value.match(/^Account geprüft — (.+) bis (.+)$/);
if (checkedUntil) return `Account checked — ${checkedUntil[1]} until ${checkedUntil[2]}`;
const checked = value.match(/^Account geprüft — (.+)$/);
if (checked) return `Account checked — ${checked[1]}`;
const invalid = value.match(/^Account ungültig — (.+)$/);
if (invalid) return `Invalid account — ${invalid[1]}`;
const queueLinks = value.match(/^(\d+) Link\(s\) zur Queue hinzugefügt$/);
if (queueLinks) return `${queueLinks[1]} link(s) added to the queue`;
const packagesAndLinks = value.match(/^(\d+) Paket\(e\), (\d+) Link\(s\) (hinzugefügt|importiert|exportiert)$/);
if (packagesAndLinks) return `${packagesAndLinks[1]} package(s), ${packagesAndLinks[2]} link(s) ${{ hinzugefügt: "added", importiert: "imported", exportiert: "exported" }[packagesAndLinks[3] as "hinzugefügt" | "importiert" | "exportiert"]}`;
const selected = value.match(/^(\d+) ausgewählt$/);
if (selected) return `${selected[1]} selected`;
const ago = value.match(/^vor (\d+) (Min|Std|Tag(?:en)?)$/);
if (ago) return `${ago[1]} ${ago[2] === "Min" ? "min" : ago[2] === "Std" ? "hr" : ago[2].startsWith("Tag") ? "day" : ago[2]} ago`;
const validation = value.match(/^(.+): (Mindestens einen Account hinzufügen|Bitte Zugangstoken eintragen|Bitte Login oder E-Mail eintragen|Bitte Passwort eintragen|Tageslimit muss eine Zahl >= 0 sein|Bitte mindestens einen gültigen API-Key eintragen)\.$/);
if (validation) {
const messages: Record<string, string> = {
"Mindestens einen Account hinzufügen": "Add at least one account",
"Bitte Zugangstoken eintragen": "Enter an access token",
"Bitte Login oder E-Mail eintragen": "Enter a login or email address",
"Bitte Passwort eintragen": "Enter a password",
"Tageslimit muss eine Zahl >= 0 sein": "The daily limit must be a number >= 0",
"Bitte mindestens einen gültigen API-Key eintragen": "Enter at least one valid API key"
};
return `${validation[1]}: ${messages[validation[2]]}.`;
}
const clipboard = value.match(/^Zwischenablage: (\d+) Link\(s\) erkannt$/);
if (clipboard) return `Clipboard: ${clipboard[1]} link(s) detected`;
const disabledKeys = value.match(/^(\d+\/\d+) API-Keys deaktiviert\.$/);
if (disabledKeys) return `${disabledKeys[1]} API keys disabled.`;
const accountCheck = value.match(/^Account-Check: (\d+\/\d+) Login gültig, (\d+) mit Premium\.$/);
if (accountCheck) return `Account check: ${accountCheck[1]} logins valid, ${accountCheck[2]} with premium.`;
const removeAccount = value.match(/^Soll (.+) wirklich aus der Accountliste entfernt werden\?$/);
if (removeAccount) return `Remove ${removeAccount[1]} from the account list?`;
const resolved = value.match(/^Konflikte gelöst: (\d+) überschrieben, (\d+) übersprungen$/);
if (resolved) return `Conflicts resolved: ${resolved[1]} overwritten, ${resolved[2]} skipped`;
const importedDlc = value.match(/^DLC importiert: (\d+) Paket\(e\), (\d+) Link\(s\)$/);
if (importedDlc) return `DLC imported: ${importedDlc[1]} package(s), ${importedDlc[2]} link(s)`;
const imported = value.match(/^(Importiert|Drag-and-Drop): (\d+) Paket\(e\), (\d+) Link\(s\)$/);
if (imported) return `${imported[1] === "Importiert" ? "Imported" : "Drag and drop"}: ${imported[2]} package(s), ${imported[3]} link(s)`;
const warnings = value.match(/^(\d+) Fehler, (\d+) Warnungen \(letzte (\d+)\)$/);
if (warnings) return `${warnings[1]} errors, ${warnings[2]} warnings (latest ${warnings[3]})`;
const capture = value.match(/^Links für (.+) lokal erfassen\.$/);
if (capture) return `Capture links locally for ${capture[1]}.`;
const copy = value.match(/^(.+) Klicken zum Kopieren$/);
if (copy) return `Click to copy ${copy[1]}`;
const cancelledPart = value.match(/^· (\d+) abgebrochen$/);
if (cancelledPart) return `· ${cancelledPart[1]} cancelled`;
const quotaUnknown = value.match(/^(.+): unbekannt \((.+)\)$/);
if (quotaUnknown) return `${quotaUnknown[1]}: unknown (${quotaUnknown[2]})`;
const quota = value.match(/^(.+): (.+) frei von (.+) \((.+)% frei\) \| (.+)$/);
if (quota) return `${quota[1]}: ${quota[2]} free of ${quota[3]} (${quota[4]}% free) | ${quota[5]}`;
const namedLimit = value.match(/^(.+): (.+) Limit muss eine Zahl >= 0 sein\.$/);
if (namedLimit) return `${namedLimit[1]}: ${namedLimit[2]} limit must be a number >= 0.`;
const dailyBlocked = value.match(/^Tageslimit erreicht, bis zum Tagesreset gesperrt(.*)$/);
if (dailyBlocked) return `Daily limit reached, blocked until the daily reset${dailyBlocked[1]}`;
const failed = value.match(/^fehlgeschlagen(.*)$/);
if (failed) return `failed${failed[1]}`;
const timeout = value.match(/^Timeout\/Abbruch(.*) → nächster Account beim Retry$/);
if (timeout) return `Timeout/cancellation${timeout[1]} → next account on retry`;
const localLimit = value.match(/^Lokales Tageslimit erreicht \((.+) \/ (.+)\)\.$/);
if (localLimit) return `Local daily limit reached (${localLimit[1]} / ${localLimit[2]}).`;
const noUpdate = value.match(/^Kein Update verfügbar \(v(.+)\)$/);
if (noUpdate) return `No update available (v${noUpdate[1]})`;
const cookies = value.match(/^(\d+) BestDebrid-Cookies importiert$/);
if (cookies) return `${cookies[1]} BestDebrid cookies imported`;
const saved = value.match(/^(.+) gespeichert$/);
if (saved) return `${saved[1]} saved`;
const removeMega = value.match(/^Soll der Mega-Debrid-Account (.+) wirklich entfernt werden\?$/);
if (removeMega) return `Remove the Mega-Debrid account ${removeMega[1]}?`;
const removeKey = value.match(/^Soll der Debrid-Link-Key (.+) wirklich entfernt werden\?$/);
if (removeKey) return `Remove the Debrid-Link key ${removeKey[1]}?`;
const counterReset = value.match(/^(.+): Tageszähler zurückgesetzt$/);
if (counterReset) return `${counterReset[1]}: Daily counter reset`;
const actionFailed = value.match(/^(.+): (Reset|Umschalten|Aktion) fehlgeschlagen: (.+)$/);
if (actionFailed) return `${actionFailed[1]}: ${{ Reset: "Reset", Umschalten: "Toggle", Aktion: "Action" }[actionFailed[2] as "Reset" | "Umschalten" | "Aktion"]} failed: ${actionFailed[3]}`;
const toggleFailed = value.match(/^(.+) konnte nicht umgeschaltet werden: (.+)$/);
if (toggleFailed) return `${toggleFailed[1]} could not be toggled: ${toggleFailed[2]}`;
const historyQuestion = value.match(/^(\d+) Einträge aus dem Verlauf entfernen\?$/);
if (historyQuestion) return `Remove ${historyQuestion[1]} entries from history?`;
const historyRemoved = value.match(/^(\d+) Verlaufseinträge entfernt$/);
if (historyRemoved) return `${historyRemoved[1]} history entries removed`;
const packageCount = value.match(/^(\d+) Paket\(e\)$/);
if (packageCount) return `${packageCount[1]} package(s)`;
const linkCount = value.match(/^(\d+) Link\(s\)$/);
if (linkCount) return `${linkCount[1]} link(s)`;
const exportSelected = value.match(/^Ausgewählte (Pakete|Dateien) exportieren \((\d+)\)$/);
if (exportSelected) return `Export selected ${exportSelected[1] === "Pakete" ? "packages" : "files"} (${exportSelected[2]})`;
const toggleAll = value.match(/^Alle (.+) umschalten$/);
if (toggleAll) return `Toggle all ${toggleAll[1]}`;
const removeSelected = value.match(/^Ausgewählte entfernen \((\d+)\)$/);
if (removeSelected) return `Remove selected (${removeSelected[1]})`;
const copied = value.match(/^(.+) kopiert$/);
if (copied) return `${copied[1]} copied`;
const suffixes: Array<[RegExp, string]> = [
[/^(.+) auswählen$/, "Select $1"], [/^(.+) einklappen$/, "Collapse $1"], [/^(.+) ausklappen$/, "Expand $1"],
[/^(.+) aktivieren$/, "Enable $1"], [/^(.+) deaktivieren$/, "Disable $1"], [/^(.+) Aktionen$/, "$1 actions"], [/^(.+) entfernen$/, "Remove $1"]
];
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
return value
.replace(/Automatisch entpacken/g, "Extract automatically")
.replace(/Wartet auf Wiederholung/g, "Waiting to retry")
.replace(/Fehlgeschlagen nach (\d+) Versuchen/g, "Failed after $1 attempts")
.replace(/(\d+) Fehler/g, "$1 errors");
} else {
const update = value.match(/^(.+) is available\. Installed version: (.+)\.$/);
if (update) return `${update[1]} ist verfügbar. Installierte Version: ${update[2]}.`;
const pagination = value.match(/^(\d+\s*[-]\s*\d+) of (\d+)$/);
if (pagination) return `${pagination[1]} von ${pagination[2]}`;
const schedule = value.match(/^Schedule rule (\d+)$/);
if (schedule) return `Zeitregel ${schedule[1]}`;
const scheduled = value.match(/^Scheduled: (.+)$/);
if (scheduled) return `Geplant: ${scheduled[1].replace(/^Today\b/, "Heute")}`;
const cancelled = value.match(/^(\d+) cancelled$/);
if (cancelled) return `${cancelled[1]} abgebrochen`;
const labelledCount = value.match(/^(Entries|Visible|Selected): (\d+)$/);
if (labelledCount) return `${({ Entries: "Einträge", Visible: "Sichtbar", Selected: "Ausgewählt" } as const)[labelledCount[1] as "Entries" | "Visible" | "Selected"]}: ${labelledCount[2]}`;
const perPage = value.match(/^(\d+) per page$/);
if (perPage) return `${perPage[1]} pro Seite`;
const filter = value.match(/^(All|Active|Queued|Paused|Completed|Errors) (\d+)$/);
if (filter) return `${enToDe.get(filter[1]) ?? filter[1]} ${filter[2]}`;
const remaining = value.match(/^(.+) of (.+) remaining$/);
if (remaining) return `${remaining[1]} von ${remaining[2]} übrig`;
const actionsFor = value.match(/^Actions for (.+)$/);
if (actionsFor) return `Aktionen für ${actionsFor[1]}`;
const assignment = value.match(/^Remove (.+) assignment$/);
if (assignment) return `${assignment[1]} Zuordnung entfernen`;
const providerFor = value.match(/^Provider for (.+)$/);
if (providerFor) return `Provider für ${providerFor[1]}`;
const move = value.match(/^Move (.+) (up|down)$/);
if (move) return `${move[1]} nach ${move[2] === "up" ? "oben" : "unten"}`;
const audio = value.match(/^Audio track: (.+)$/);
if (audio) return `Tonspur: ${audio[1].replace(/without DE tag/g, "ohne DE-Tag").replace(/ffmpeg missing/g, "ffmpeg fehlt").replace(/(\d+) errors/g, "$1 Fehler")}`;
const result = value.match(/^(\d+\/\d+) completed(.*)$/);
if (result) return `${result[1]} fertig${result[2].replace(/(\d+) errors/g, "$1 Fehler").replace(/(\d+) cancelled/g, "$1 abgebrochen")}`;
const extracting = value.match(/^Extracting (\d+%)$/);
if (extracting) return `Entpacken ${extracting[1]}`;
const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/);
if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`;
const checked = value.match(/^Account checked — (.+)$/);
if (checked) return `Account geprüft — ${checked[1]}`;
const invalid = value.match(/^Invalid account — (.+)$/);
if (invalid) return `Account ungültig — ${invalid[1]}`;
const queueLinks = value.match(/^(\d+) link\(s\) added to the queue$/);
if (queueLinks) return `${queueLinks[1]} Link(s) zur Queue hinzugefügt`;
const packagesAndLinks = value.match(/^(\d+) package\(s\), (\d+) link\(s\) (added|imported|exported)$/);
if (packagesAndLinks) return `${packagesAndLinks[1]} Paket(e), ${packagesAndLinks[2]} Link(s) ${{ added: "hinzugefügt", imported: "importiert", exported: "exportiert" }[packagesAndLinks[3] as "added" | "imported" | "exported"]}`;
const selected = value.match(/^(\d+) selected$/);
if (selected) return `${selected[1]} ausgewählt`;
const ago = value.match(/^(\d+) (min|hr|day) ago$/);
if (ago) return `vor ${ago[1]} ${ago[2] === "min" ? "Min" : ago[2] === "hr" ? "Std" : "Tag"}`;
const validation = value.match(/^(.+): (Add at least one account|Enter an access token|Enter a login or email address|Enter a password|The daily limit must be a number >= 0|Enter at least one valid API key)\.$/);
if (validation) {
const messages: Record<string, string> = {
"Add at least one account": "Mindestens einen Account hinzufügen",
"Enter an access token": "Bitte Zugangstoken eintragen",
"Enter a login or email address": "Bitte Login oder E-Mail eintragen",
"Enter a password": "Bitte Passwort eintragen",
"The daily limit must be a number >= 0": "Tageslimit muss eine Zahl >= 0 sein",
"Enter at least one valid API key": "Bitte mindestens einen gültigen API-Key eintragen"
};
return `${validation[1]}: ${messages[validation[2]]}.`;
}
const clipboard = value.match(/^Clipboard: (\d+) link\(s\) detected$/);
if (clipboard) return `Zwischenablage: ${clipboard[1]} Link(s) erkannt`;
const disabledKeys = value.match(/^(\d+\/\d+) API keys disabled\.$/);
if (disabledKeys) return `${disabledKeys[1]} API-Keys deaktiviert.`;
const accountCheck = value.match(/^Account check: (\d+\/\d+) logins valid, (\d+) with premium\.$/);
if (accountCheck) return `Account-Check: ${accountCheck[1]} Login gültig, ${accountCheck[2]} mit Premium.`;
const removeAccount = value.match(/^Remove (.+) from the account list\?$/);
if (removeAccount) return `Soll ${removeAccount[1]} wirklich aus der Accountliste entfernt werden?`;
const resolved = value.match(/^Conflicts resolved: (\d+) overwritten, (\d+) skipped$/);
if (resolved) return `Konflikte gelöst: ${resolved[1]} überschrieben, ${resolved[2]} übersprungen`;
const importedDlc = value.match(/^DLC imported: (\d+) package\(s\), (\d+) link\(s\)$/);
if (importedDlc) return `DLC importiert: ${importedDlc[1]} Paket(e), ${importedDlc[2]} Link(s)`;
const imported = value.match(/^(Imported|Drag and drop): (\d+) package\(s\), (\d+) link\(s\)$/);
if (imported) return `${imported[1] === "Imported" ? "Importiert" : "Drag-and-Drop"}: ${imported[2]} Paket(e), ${imported[3]} Link(s)`;
const warnings = value.match(/^(\d+) errors, (\d+) warnings \(latest (\d+)\)$/);
if (warnings) return `${warnings[1]} Fehler, ${warnings[2]} Warnungen (letzte ${warnings[3]})`;
const capture = value.match(/^Capture links locally for (.+)\.$/);
if (capture) return `Links für ${capture[1]} lokal erfassen.`;
const copy = value.match(/^Click to copy (.+)$/);
if (copy) return `${copy[1]} Klicken zum Kopieren`;
const cancelledPart = value.match(/^· (\d+) cancelled$/);
if (cancelledPart) return `· ${cancelledPart[1]} abgebrochen`;
const quotaUnknown = value.match(/^(.+): unknown \((.+)\)$/);
if (quotaUnknown) return `${quotaUnknown[1]}: unbekannt (${quotaUnknown[2]})`;
const quota = value.match(/^(.+): (.+) free of (.+) \((.+)% free\) \| (.+)$/);
if (quota) return `${quota[1]}: ${quota[2]} frei von ${quota[3]} (${quota[4]}% frei) | ${quota[5]}`;
const namedLimit = value.match(/^(.+): (.+) limit must be a number >= 0\.$/);
if (namedLimit) return `${namedLimit[1]}: ${namedLimit[2]} Limit muss eine Zahl >= 0 sein.`;
const dailyBlocked = value.match(/^Daily limit reached, blocked until the daily reset(.*)$/);
if (dailyBlocked) return `Tageslimit erreicht, bis zum Tagesreset gesperrt${dailyBlocked[1]}`;
const failed = value.match(/^failed(.*)$/);
if (failed) return `fehlgeschlagen${failed[1]}`;
const timeout = value.match(/^Timeout\/cancellation(.*) → next account on retry$/);
if (timeout) return `Timeout/Abbruch${timeout[1]} → nächster Account beim Retry`;
const localLimit = value.match(/^Local daily limit reached \((.+) \/ (.+)\)\.$/);
if (localLimit) return `Lokales Tageslimit erreicht (${localLimit[1]} / ${localLimit[2]}).`;
const noUpdate = value.match(/^No update available \(v(.+)\)$/);
if (noUpdate) return `Kein Update verfügbar (v${noUpdate[1]})`;
const cookies = value.match(/^(\d+) BestDebrid cookies imported$/);
if (cookies) return `${cookies[1]} BestDebrid-Cookies importiert`;
const saved = value.match(/^(.+) saved$/);
if (saved) return `${saved[1]} gespeichert`;
const removeMega = value.match(/^Remove the Mega-Debrid account (.+)\?$/);
if (removeMega) return `Soll der Mega-Debrid-Account ${removeMega[1]} wirklich entfernt werden?`;
const removeKey = value.match(/^Remove the Debrid-Link key (.+)\?$/);
if (removeKey) return `Soll der Debrid-Link-Key ${removeKey[1]} wirklich entfernt werden?`;
const counterReset = value.match(/^(.+): Daily counter reset$/);
if (counterReset) return `${counterReset[1]}: Tageszähler zurückgesetzt`;
const actionFailed = value.match(/^(.+): (Reset|Toggle|Action) failed: (.+)$/);
if (actionFailed) return `${actionFailed[1]}: ${{ Reset: "Reset", Toggle: "Umschalten", Action: "Aktion" }[actionFailed[2] as "Reset" | "Toggle" | "Action"]} fehlgeschlagen: ${actionFailed[3]}`;
const toggleFailed = value.match(/^(.+) could not be toggled: (.+)$/);
if (toggleFailed) return `${toggleFailed[1]} konnte nicht umgeschaltet werden: ${toggleFailed[2]}`;
const historyQuestion = value.match(/^Remove (\d+) entries from history\?$/);
if (historyQuestion) return `${historyQuestion[1]} Einträge aus dem Verlauf entfernen?`;
const historyRemoved = value.match(/^(\d+) history entries removed$/);
if (historyRemoved) return `${historyRemoved[1]} Verlaufseinträge entfernt`;
const packageCount = value.match(/^(\d+) package\(s\)$/);
if (packageCount) return `${packageCount[1]} Paket(e)`;
const linkCount = value.match(/^(\d+) link\(s\)$/);
if (linkCount) return `${linkCount[1]} Link(s)`;
const exportSelected = value.match(/^Export selected (packages|files) \((\d+)\)$/);
if (exportSelected) return `Ausgewählte ${exportSelected[1] === "packages" ? "Pakete" : "Dateien"} exportieren (${exportSelected[2]})`;
const toggleAll = value.match(/^Toggle all (.+)$/);
if (toggleAll) return `Alle ${toggleAll[1]} umschalten`;
const removeSelected = value.match(/^Remove selected \((\d+)\)$/);
if (removeSelected) return `Ausgewählte entfernen (${removeSelected[1]})`;
const copied = value.match(/^(.+) copied$/);
if (copied) return `${copied[1]} kopiert`;
const suffixes: Array<[RegExp, string]> = [
[/^Select (.+)$/, "$1 auswählen"], [/^Collapse (.+)$/, "$1 einklappen"], [/^Expand (.+)$/, "$1 ausklappen"],
[/^Enable (.+)$/, "$1 aktivieren"], [/^Disable (.+)$/, "$1 deaktivieren"], [/^(.+) actions$/, "$1 Aktionen"], [/^Remove (.+)$/, "$1 entfernen"]
];
for (const [pattern, replacement] of suffixes) if (pattern.test(value)) return value.replace(pattern, replacement);
return value
.replace(/Extract automatically/g, "Automatisch entpacken")
.replace(/Waiting to retry/g, "Wartet auf Wiederholung")
.replace(/Failed after (\d+) attempts/g, "Fehlgeschlagen nach $1 Versuchen")
.replace(/(\d+) errors/g, "$1 Fehler");
}
}
export function translateUiText(value: string, language: AppLanguage): string {
const leading = value.match(/^\s*/)?.[0] ?? "";
const trailing = value.match(/\s*$/)?.[0] ?? "";
const core = value.slice(leading.length, value.length - trailing.length);
if (!core) return value;
const translated = (language === "en" ? deToEn : enToDe).get(core) ?? translateDynamic(core, language);
return `${leading}${translated}${trailing}`;
}
type Localizer = { setLanguage: (language: AppLanguage) => void; disconnect: () => void };
export function createUiLocalizer(documentRef: Document, initialLanguage: AppLanguage): Localizer {
let language = initialLanguage;
const sourceText = new WeakMap<Text, string>();
const renderedText = new WeakMap<Text, string>();
const sourceAttributes = new WeakMap<Element, Map<string, string>>();
const renderedAttributes = new WeakMap<Element, Map<string, string>>();
const ignored = (node: Node): boolean => node.parentElement?.closest('[data-i18n-ignore="true"]') !== null;
const localizeText = (node: Text): void => {
if (ignored(node)) return;
const current = node.data;
const lastRendered = renderedText.get(node);
if (!sourceText.has(node) || (lastRendered !== undefined && current !== lastRendered)) sourceText.set(node, current);
const next = translateUiText(sourceText.get(node) ?? current, language);
renderedText.set(node, next);
if (current !== next) node.data = next;
};
const localizeElement = (element: Element): void => {
if (element.closest('[data-i18n-ignore="true"]')) return;
let sources = sourceAttributes.get(element);
let rendered = renderedAttributes.get(element);
if (!sources) { sources = new Map(); sourceAttributes.set(element, sources); }
if (!rendered) { rendered = new Map(); renderedAttributes.set(element, rendered); }
for (const attribute of attributes) {
const current = element.getAttribute(attribute);
if (current === null) continue;
const lastRendered = rendered.get(attribute);
if (!sources.has(attribute) || (lastRendered !== undefined && current !== lastRendered)) sources.set(attribute, current);
const next = translateUiText(sources.get(attribute) ?? current, language);
rendered.set(attribute, next);
if (current !== next) element.setAttribute(attribute, next);
}
};
const localizeTree = (root: Node): void => {
if (root.nodeType === Node.TEXT_NODE) localizeText(root as Text);
if (root.nodeType === Node.ELEMENT_NODE) localizeElement(root as Element);
const walker = documentRef.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.nodeType === Node.TEXT_NODE) localizeText(node as Text);
else localizeElement(node as Element);
node = walker.nextNode();
}
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === "characterData") localizeText(mutation.target as Text);
else if (mutation.type === "attributes") localizeElement(mutation.target as Element);
else for (const node of mutation.addedNodes) localizeTree(node);
}
});
const start = (): void => {
documentRef.documentElement.lang = language;
localizeTree(documentRef.body);
observer.observe(documentRef.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
};
start();
return {
setLanguage(nextLanguage) {
language = nextLanguage;
documentRef.documentElement.lang = language;
localizeTree(documentRef.body);
},
disconnect() { observer.disconnect(); }
};
}
+16 -3
View File
@@ -1,6 +1,19 @@
import type { SessionState } from "../shared/types";
/**
import type { SessionState } from "../shared/types";
const DOWNLOAD_SELECTION_PRESERVE_SELECTOR = ".downloads-package-card, .downloads-item-row, .downloads-selection-cell, .package-card, .ctx-menu, .modal-backdrop, .modal-card";
export function shouldClearDownloadSelection(target: Pick<Element, "closest">): boolean {
return !target.closest(DOWNLOAD_SELECTION_PRESERVE_SELECTOR);
}
export function shouldClearDownloadSelectionOnEscape(tagName: string, inputType = ""): boolean {
const normalizedTag = tagName.toUpperCase();
if (normalizedTag === "TEXTAREA") return false;
if (normalizedTag !== "INPUT") return true;
return ["checkbox", "radio", "button"].includes(inputType.toLowerCase());
}
/**
* Drop selected ids whose package OR item no longer exists in the session.
* The selection set mixes package and item ids; when entries vanish (delta
* removal, backup-driven session swap, completed-cleanup) a stale id would
+63 -30
View File
@@ -1,38 +1,71 @@
import type { ReactElement, ReactNode } from "react";
import { useEffect, useLayoutEffect, useRef, type ReactElement, type ReactNode } from "react";
import appIconUrl from "../../../assets/app_icon.png";
import { Icon } from "../ui/Icon";
import { buildMainNavigation, type MainView } from "./shell-model";
import { buildMainNavigation, type MainView } from "./shell-model";
export interface AppHeaderProps {
activeView: MainView;
onViewChange: (view: MainView) => void;
actions: ReactNode;
activeView: MainView;
onViewChange: (view: MainView) => void;
actions: ReactNode;
}
const useHeaderLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export function AppHeader({ activeView, onViewChange, actions }: AppHeaderProps): ReactElement {
const navigation = buildMainNavigation(activeView);
const navigationRef = useRef<HTMLElement>(null);
return (
<header className="md-shell-header" data-ui-region="header">
<div className="md-shell-brand">Multi-Debrid-Downloader</div>
<nav aria-label="Hauptnavigation" className="md-shell-navigation" role="tablist">
{navigation.map((item) => (
<button
aria-current={item.active ? "page" : undefined}
aria-selected={item.active}
useHeaderLayoutEffect(() => {
const element = navigationRef.current;
if (!element) return;
let frame = 0;
const sync = (): void => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const activeItem = element.querySelector<HTMLElement>(`.md-shell-navigation-item[data-main-view="${activeView}"]`);
if (!activeItem) return;
const navigationRect = element.getBoundingClientRect();
const itemRect = activeItem.getBoundingClientRect();
element.style.setProperty("--md-navigation-active-x", `${itemRect.left - navigationRect.left}px`);
element.style.setProperty("--md-navigation-active-y", `${itemRect.top - navigationRect.top}px`);
element.style.setProperty("--md-navigation-active-width", `${itemRect.width}px`);
element.style.setProperty("--md-navigation-active-height", `${itemRect.height}px`);
element.classList.add("has-active-indicator");
});
};
const observer = new ResizeObserver(sync);
observer.observe(element);
element.querySelectorAll<HTMLElement>(".md-shell-navigation-item").forEach((item) => observer.observe(item));
sync();
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
};
}, [activeView]);
return (
<header className="md-shell-header" data-ui-region="header">
<div className="md-shell-brand"><img alt="" className="md-shell-brand-mark" src={appIconUrl} /><span>Multi-Debrid Downloader</span></div>
<nav aria-label="Hauptnavigation" className="md-shell-navigation" ref={navigationRef} role="tablist">
{navigation.map((item) => (
<button
aria-current={item.active ? "page" : undefined}
aria-selected={item.active}
className={`md-shell-navigation-item${item.active ? " is-active" : ""}`}
data-visual-active-view={item.active ? item.id : undefined}
key={item.id}
onClick={() => onViewChange(item.id)}
role="tab"
title={item.label}
type="button"
>
<Icon name={item.icon} size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
{actions ? <div aria-label="Globale Aktionen" className="md-shell-header-actions" role="group">{actions}</div> : null}
</header>
);
}
data-main-view={item.id}
data-visual-active-view={item.active ? item.id : undefined}
key={item.id}
onClick={() => onViewChange(item.id)}
role="tab"
title={item.label}
type="button"
>
<Icon name={item.icon} size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
{actions ? <div aria-label="Globale Aktionen" className="md-shell-header-actions" role="group">{actions}</div> : null}
</header>
);
}
+1 -1
View File
@@ -113,7 +113,7 @@ export function UpdateExperience({
{releaseNotes.trim() ? (
<details className="md-update-release-notes">
<summary>Changelog anzeigen</summary>
<pre>{releaseNotes}</pre>
<pre data-i18n-ignore="true">{releaseNotes}</pre>
</details>
) : null}
</>
File diff suppressed because it is too large Load Diff
+84 -25
View File
@@ -1,24 +1,24 @@
:root {
font-family: "Manrope", "Segoe UI Variable", "Segoe UI", sans-serif;
--bg: #050810;
--bg-glow: #1e1730;
--surface: #0c1322;
--card: #12192c;
--field: #090f1c;
--border: #283447;
--text: #ece6dd;
--muted: #a59c8e;
--accent: #f2942d;
--accent-2: #ff7a5c;
--bg: #0f0f0f;
--bg-glow: #181818;
--surface: #232323;
--card: #232323;
--field: #1b1b1b;
--border: #3d3d3d;
--text: #ffffff;
--muted: #a5a5a5;
--accent: #4a4a4a;
--accent-2: #636363;
--danger: #f4564b;
--button-bg: #0d1a2c;
--button-bg-hover: #12243d;
--tab-bg: #0b1321;
--tab-active: #14253e;
--toast-bg: #0f1f33;
--toast-border: #2a4f78;
--progress-track: #0b1628;
--overlay: rgba(8, 13, 23, 0.85);
--button-bg: #2b2b2b;
--button-bg-hover: #373737;
--tab-bg: #1b1b1b;
--tab-active: #383838;
--toast-bg: #262626;
--toast-border: #4a4a4a;
--progress-track: #1b1b1b;
--overlay: rgba(0, 0, 0, 0.72);
}
:root[data-theme="dark"] {
@@ -48,9 +48,54 @@
--overlay: rgba(229, 238, 252, 0.88);
}
* {
box-sizing: border-box;
}
* {
box-sizing: border-box;
}
.ui-sliding-selection {
position: relative;
isolation: isolate;
}
.ui-sliding-selection::before {
content: "";
position: absolute;
z-index: 0;
top: 0;
left: 0;
width: var(--ui-sliding-selection-width, 0px);
height: var(--ui-sliding-selection-height, 0px);
border: 1px solid var(--ui-sliding-selection-border, var(--ui-border));
border-radius: var(--ui-sliding-selection-radius, 6px);
background: var(--ui-sliding-selection-background, var(--ui-active));
opacity: 1;
pointer-events: none;
transform: translate3d(var(--ui-sliding-selection-x, 0px), var(--ui-sliding-selection-y, 0px), 0);
transition: transform var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1), width var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1), height var(--ui-sliding-selection-duration, 0ms) cubic-bezier(0.22, 0.76, 0.22, 1);
}
.ui-sliding-selection > [data-sliding-selection-item="true"] {
position: relative;
z-index: 1;
}
.ui-sliding-selection-horizontal::before {
top: auto;
bottom: 0;
height: 2px;
border: 0;
border-radius: 2px 2px 0 0;
background: var(--ui-accent);
transform: translate3d(var(--ui-sliding-selection-x, 0px), 0, 0);
}
@media (prefers-reduced-motion: reduce) {
.ui-sliding-selection::before {
transition-property: transform, width, height !important;
transition-duration: var(--ui-sliding-selection-duration, 0ms), var(--ui-sliding-selection-duration, 0ms), var(--ui-sliding-selection-duration, 0ms) !important;
transition-timing-function: cubic-bezier(0.22, 0.76, 0.22, 1), cubic-bezier(0.22, 0.76, 0.22, 1), cubic-bezier(0.22, 0.76, 0.22, 1) !important;
}
}
html,
body,
@@ -101,7 +146,7 @@ body,
background: var(--button-bg-hover);
}
.menu-dropdown {
.menu-dropdown {
position: absolute;
top: 100%;
left: 0;
@@ -111,9 +156,23 @@ body,
border: 1px solid var(--border);
border-radius: 8px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
white-space: nowrap;
}
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(-8px);
transform-origin: top;
visibility: hidden;
pointer-events: none;
transition: opacity 150ms ease, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s linear 180ms;
white-space: nowrap;
}
.menu-dropdown.is-open {
opacity: 1;
transform: translateY(0);
visibility: visible;
pointer-events: auto;
transition-delay: 0s;
}
.menu-dropdown-item {
display: flex;
+394 -392
View File
@@ -1,392 +1,394 @@
:root,
:root[data-theme="dark"] {
--ui-canvas: #0F0F0F;
--ui-surface: #232323;
--ui-input: #2B2B2B;
--ui-table-header: #313131;
--ui-active: #333436;
--ui-hover: #373535;
--ui-tooltip: #4F4D4D;
--ui-border: #3D3D3D;
--ui-text: #FFFFFF;
--ui-text-secondary: #EAEDF3;
--ui-text-muted: #919191;
--ui-primary: #BAD0FC;
--ui-primary-hover: #8AA5DC;
--ui-accent: #3886FF;
--ui-warning: #F1C786;
--ui-danger: #F06464;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #35383D;
--ui-overlay: rgba(0, 0, 0, 0.60);
color-scheme: dark;
}
:root[data-theme="light"] {
--ui-canvas: #F3F4F6;
--ui-surface: #FFFFFF;
--ui-input: #F7F8FA;
--ui-table-header: #E7E9ED;
--ui-active: #DEE6F5;
--ui-hover: #E8ECF3;
--ui-tooltip: #35383D;
--ui-border: #D0D4DB;
--ui-text: #181A1F;
--ui-text-secondary: #343842;
--ui-text-muted: #667085;
--ui-primary: #A9C2F3;
--ui-primary-hover: #8AA5DC;
--ui-accent: #256FDB;
--ui-warning: #E8B85D;
--ui-danger: #D94747;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #E7E9ED;
--ui-overlay: rgba(0, 0, 0, 0.45);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 0;
min-height: 100%;
}
body {
margin: 0;
background: var(--ui-canvas);
color: var(--ui-text);
font-family: Inter, "Segoe UI Variable", "Segoe UI", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
text-rendering: optimizeLegibility;
user-select: none;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
color: inherit;
}
input,
textarea,
[contenteditable="true"] {
user-select: text;
}
* {
scrollbar-color: var(--ui-border) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: var(--ui-border);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
.ui-icon {
display: block;
flex: 0 0 auto;
pointer-events: none;
}
.ui-input {
min-width: 0;
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 6px;
outline: none;
background: var(--ui-input);
color: var(--ui-text);
}
.ui-input::placeholder {
color: var(--ui-text-muted);
opacity: 1;
}
.ui-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
:where(
button,
[href],
input,
select,
textarea,
summary,
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.ui-toolbar {
display: flex;
min-width: 0;
min-height: 59px;
align-items: center;
gap: 8px;
padding: 11px 12px 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui-toolbar-group {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.ui-toolbar-search {
position: relative;
display: flex;
width: min(280px, 100%);
margin-left: auto;
align-items: center;
}
.ui-toolbar-search-icon {
position: absolute;
left: 10px;
color: var(--ui-text-muted);
}
.ui-toolbar-search-input {
width: 100%;
padding: 7px 12px 7px 34px;
}
.ui-data-table {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
background: var(--ui-surface);
color: var(--ui-text);
}
.ui-data-table-header {
min-height: 41px;
flex: 0 0 41px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
}
.ui-data-table-body {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: auto;
}
.ui-data-table-empty {
display: flex;
min-height: 100%;
flex: 1 1 auto;
}
.ui-data-table-empty-cell {
display: flex;
max-width: 440px;
margin: auto;
align-items: center;
flex-direction: column;
gap: 8px;
padding: 32px 24px;
color: var(--ui-text-muted);
text-align: center;
}
.ui-data-table-empty-illustration {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 4px;
place-items: center;
color: var(--ui-text-secondary);
}
.ui-data-table-empty-title {
color: var(--ui-text);
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.ui-data-table-empty-description {
color: var(--ui-text-muted);
}
.ui-data-table-footer {
display: flex;
min-height: 61px;
flex: 0 0 61px;
align-items: center;
justify-content: flex-end;
gap: 24px;
padding: 0 16px;
border-top: 1px solid var(--ui-border);
background: var(--ui-surface);
color: var(--ui-text-secondary);
font-size: 12px;
line-height: 16px;
}
.ui-context-info {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 20;
display: flex;
align-items: flex-end;
gap: 8px;
}
.ui-context-info-trigger {
display: inline-grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-surface);
color: var(--ui-text-secondary);
cursor: pointer;
}
.ui-context-info-trigger:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.ui-context-info-region {
width: min(360px, calc(100vw - 80px));
max-height: min(320px, calc(100vh - 96px));
overflow: auto;
padding: 12px 16px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-tooltip);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
color: #FFFFFF;
}
.ui-error-boundary {
position: fixed;
inset: 0;
z-index: 10000;
overflow: auto;
padding: clamp(24px, 5vw, 48px);
background: var(--ui-canvas);
color: var(--ui-text);
text-align: center;
}
.ui-error-boundary-content {
display: flex;
width: min(640px, 100%);
min-height: 100%;
margin: auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.ui-error-boundary-title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.ui-error-boundary-description {
max-width: 560px;
margin: 0;
color: var(--ui-text-secondary);
}
.ui-error-boundary-details {
width: 100%;
max-height: min(200px, 40vh);
margin: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-danger);
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
text-align: left;
user-select: text;
white-space: pre-wrap;
}
.ui-error-boundary-reload {
min-height: 40px;
padding: 8px 20px;
border: 0;
border-radius: 6px;
background: var(--ui-primary);
color: var(--ui-error-action-text);
cursor: pointer;
font-weight: 600;
}
.ui-error-boundary-reload:hover {
background: var(--ui-primary-hover);
}
.ui-error-boundary-details:focus-visible,
.ui-error-boundary-reload:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
:root,
:root[data-theme="dark"] {
--ui-canvas: #0F0F0F;
--ui-surface: #232323;
--ui-input: #2B2B2B;
--ui-table-header: #313131;
--ui-active: #333436;
--ui-hover: #373535;
--ui-tooltip: #4F4D4D;
--ui-border: #3D3D3D;
--ui-text: #FFFFFF;
--ui-text-secondary: #EAEDF3;
--ui-text-muted: #919191;
--ui-primary: #D6D6D6;
--ui-primary-hover: #E6E6E6;
--ui-accent: #4A4A4A;
--ui-success: #4ADE80;
--ui-warning: #F1C786;
--ui-danger: #F06464;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #35383D;
--ui-overlay: rgba(0, 0, 0, 0.60);
color-scheme: dark;
}
:root[data-theme="light"] {
--ui-canvas: #F3F4F6;
--ui-surface: #FFFFFF;
--ui-input: #F7F8FA;
--ui-table-header: #E7E9ED;
--ui-active: #DEE6F5;
--ui-hover: #E8ECF3;
--ui-tooltip: #35383D;
--ui-border: #D0D4DB;
--ui-text: #181A1F;
--ui-text-secondary: #343842;
--ui-text-muted: #667085;
--ui-primary: #3A3A3A;
--ui-primary-hover: #202020;
--ui-accent: #5E5E5E;
--ui-success: #1E9E55;
--ui-warning: #E8B85D;
--ui-danger: #D94747;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #E7E9ED;
--ui-overlay: rgba(0, 0, 0, 0.45);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 0;
min-height: 100%;
}
body {
margin: 0;
background: var(--ui-canvas);
color: var(--ui-text);
font-family: Inter, "Segoe UI Variable", "Segoe UI", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
text-rendering: optimizeLegibility;
user-select: none;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
color: inherit;
}
input,
textarea,
[contenteditable="true"] {
user-select: text;
}
* {
scrollbar-color: var(--ui-border) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: var(--ui-border);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
.ui-icon {
display: block;
flex: 0 0 auto;
pointer-events: none;
}
.ui-input {
min-width: 0;
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 6px;
outline: none;
background: var(--ui-input);
color: var(--ui-text);
}
.ui-input::placeholder {
color: var(--ui-text-muted);
opacity: 1;
}
.ui-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
:where(
button,
[href],
input,
select,
textarea,
summary,
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.ui-toolbar {
display: flex;
min-width: 0;
min-height: 59px;
align-items: center;
gap: 8px;
padding: 11px 12px 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui-toolbar-group {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.ui-toolbar-search {
position: relative;
display: flex;
width: min(280px, 100%);
margin-left: auto;
align-items: center;
}
.ui-toolbar-search-icon {
position: absolute;
left: 10px;
color: var(--ui-text-muted);
}
.ui-toolbar-search-input {
width: 100%;
padding: 7px 12px 7px 34px;
}
.ui-data-table {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
background: var(--ui-surface);
color: var(--ui-text);
}
.ui-data-table-header {
min-height: 41px;
flex: 0 0 41px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
}
.ui-data-table-body {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: auto;
}
.ui-data-table-empty {
display: flex;
min-height: 100%;
flex: 1 1 auto;
}
.ui-data-table-empty-cell {
display: flex;
max-width: 440px;
margin: auto;
align-items: center;
flex-direction: column;
gap: 8px;
padding: 32px 24px;
color: var(--ui-text-muted);
text-align: center;
}
.ui-data-table-empty-illustration {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 4px;
place-items: center;
color: var(--ui-text-secondary);
}
.ui-data-table-empty-title {
color: var(--ui-text);
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.ui-data-table-empty-description {
color: var(--ui-text-muted);
}
.ui-data-table-footer {
display: flex;
min-height: 61px;
flex: 0 0 61px;
align-items: center;
justify-content: flex-end;
gap: 24px;
padding: 0 16px;
border-top: 1px solid var(--ui-border);
background: var(--ui-surface);
color: var(--ui-text-secondary);
font-size: 12px;
line-height: 16px;
}
.ui-context-info {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 20;
display: flex;
align-items: flex-end;
gap: 8px;
}
.ui-context-info-trigger {
display: inline-grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-surface);
color: var(--ui-text-secondary);
cursor: pointer;
}
.ui-context-info-trigger:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.ui-context-info-region {
width: min(360px, calc(100vw - 80px));
max-height: min(320px, calc(100vh - 96px));
overflow: auto;
padding: 12px 16px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-tooltip);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
color: #FFFFFF;
}
.ui-error-boundary {
position: fixed;
inset: 0;
z-index: 10000;
overflow: auto;
padding: clamp(24px, 5vw, 48px);
background: var(--ui-canvas);
color: var(--ui-text);
text-align: center;
}
.ui-error-boundary-content {
display: flex;
width: min(640px, 100%);
min-height: 100%;
margin: auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.ui-error-boundary-title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.ui-error-boundary-description {
max-width: 560px;
margin: 0;
color: var(--ui-text-secondary);
}
.ui-error-boundary-details {
width: 100%;
max-height: min(200px, 40vh);
margin: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-danger);
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
text-align: left;
user-select: text;
white-space: pre-wrap;
}
.ui-error-boundary-reload {
min-height: 40px;
padding: 8px 20px;
border: 0;
border-radius: 6px;
background: var(--ui-primary);
color: var(--ui-error-action-text);
cursor: pointer;
font-weight: 600;
}
.ui-error-boundary-reload:hover {
background: var(--ui-primary-hover);
}
.ui-error-boundary-details:focus-visible,
.ui-error-boundary-reload:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactElement } from "react";
export type RollingMetricDirection = "up" | "down" | "none";
interface RollingMetricValueProps {
numericValue: number;
value: string;
}
interface RollingMetricTransition {
direction: Exclude<RollingMetricDirection, "none">;
from: string;
id: number;
to: string;
}
const useMetricLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export function getRollingMetricDirection(previous: number, next: number): RollingMetricDirection {
if (next > previous) return "up";
if (next < previous) return "down";
return "none";
}
export function RollingMetricValue({ numericValue, value }: RollingMetricValueProps): ReactElement {
const previousRef = useRef({ numericValue, value });
const sequenceRef = useRef(0);
const outgoingRef = useRef<HTMLSpanElement>(null);
const incomingRef = useRef<HTMLSpanElement>(null);
const [transition, setTransition] = useState<RollingMetricTransition | null>(null);
useMetricLayoutEffect(() => {
const previous = previousRef.current;
if (previous.value === value && previous.numericValue === numericValue) return;
previousRef.current = { numericValue, value };
const direction = getRollingMetricDirection(previous.numericValue, numericValue);
if (direction === "none") {
setTransition(null);
return;
}
sequenceRef.current += 1;
setTransition({ direction, from: previous.value, id: sequenceRef.current, to: value });
}, [numericValue, value]);
useMetricLayoutEffect(() => {
if (!transition || !outgoingRef.current || !incomingRef.current) return;
const distance = transition.direction === "up" ? -1 : 1;
const options: KeyframeAnimationOptions = { duration: 320, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" };
const outgoing = outgoingRef.current.animate([
{ opacity: 1, transform: "translateY(0)" },
{ opacity: 0, transform: `translateY(${distance * 115}%)` }
], options);
const incoming = incomingRef.current.animate([
{ opacity: 0, transform: `translateY(${-distance * 115}%)` },
{ opacity: 1, transform: "translateY(0)" }
], options);
let active = true;
Promise.all([outgoing.finished, incoming.finished]).then(() => {
if (active) setTransition((current) => current?.id === transition.id ? null : current);
}).catch(() => {});
return () => {
active = false;
outgoing.cancel();
incoming.cancel();
};
}, [transition]);
return (
<strong aria-label={value} className="downloads-rolling-value" data-direction={transition?.direction ?? "none"}>
{transition
? <><span aria-hidden="true" className="downloads-rolling-value-layer is-outgoing" ref={outgoingRef}>{transition.from}</span><span aria-hidden="true" className="downloads-rolling-value-layer is-incoming" ref={incomingRef}>{transition.to}</span></>
: <span aria-hidden="true" className="downloads-rolling-value-layer">{value}</span>}
</strong>
);
}
+81
View File
@@ -0,0 +1,81 @@
import { createElement, useEffect, useLayoutEffect, useRef, type HTMLAttributes, type ReactElement, type ReactNode } from "react";
const useSelectionLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
export interface SlidingSelectionProps extends HTMLAttributes<HTMLElement> {
activeKey: string;
as?: "div" | "nav";
axis: "horizontal" | "vertical";
children: ReactNode;
}
export function scheduleSelectionLayout(
hasPosition: boolean,
frame: number,
apply: () => void,
requestFrame: (callback: FrameRequestCallback) => number = requestAnimationFrame,
cancelFrame: (frame: number) => void = cancelAnimationFrame,
enableTransitions: () => void = () => {}
): number {
if (!hasPosition) {
apply();
return requestFrame(enableTransitions);
}
cancelFrame(frame);
return requestFrame(apply);
}
export function SlidingSelection({ activeKey, as = "div", axis, children, className = "", ...attributes }: SlidingSelectionProps): ReactElement {
const ref = useRef<HTMLElement>(null);
const hasPositionRef = useRef(false);
useSelectionLayoutEffect(() => {
const element = ref.current;
if (!element) return;
let frame = 0;
let transitionFrame = 0;
const applyLayout = (): void => {
const active = element.querySelector<HTMLElement>('[data-sliding-selection-active="true"]');
if (!active) {
element.style.setProperty("--ui-sliding-selection-width", "0px");
element.style.setProperty("--ui-sliding-selection-height", "0px");
return;
}
const containerRect = element.getBoundingClientRect();
const activeRect = active.getBoundingClientRect();
element.style.setProperty("--ui-sliding-selection-x", `${activeRect.left - containerRect.left + element.scrollLeft}px`);
element.style.setProperty("--ui-sliding-selection-y", `${activeRect.top - containerRect.top + element.scrollTop}px`);
element.style.setProperty("--ui-sliding-selection-width", `${activeRect.width}px`);
element.style.setProperty("--ui-sliding-selection-height", `${activeRect.height}px`);
hasPositionRef.current = true;
};
const sync = (): void => {
if (!hasPositionRef.current) {
transitionFrame = scheduleSelectionLayout(false, transitionFrame, applyLayout, requestAnimationFrame, cancelAnimationFrame, () => {
if (hasPositionRef.current) {
element.style.setProperty("--ui-sliding-selection-duration", "420ms");
}
});
return;
}
frame = scheduleSelectionLayout(hasPositionRef.current, frame, applyLayout);
};
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
observer?.observe(element);
element.querySelectorAll<HTMLElement>('[data-sliding-selection-item="true"]').forEach((item) => observer?.observe(item));
element.addEventListener("scroll", sync, { passive: true });
sync();
return () => {
cancelAnimationFrame(frame);
cancelAnimationFrame(transitionFrame);
observer?.disconnect();
element.removeEventListener("scroll", sync);
};
}, [activeKey]);
return createElement(as, {
...attributes,
className: `ui-sliding-selection ui-sliding-selection-${axis}${className ? ` ${className}` : ""}`,
ref
}, children);
}
+205 -204
View File
@@ -1,208 +1,209 @@
import type { ChangeEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import type { ChangeEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import type { CollectorViewModel } from "./collector-model";
import "./collector.css";
export interface CollectorViewActions {
onTabSelect: (tabId: string) => void;
onTabAdd: () => void;
onTabRemove: (tabId: string) => void;
onOpenInput: () => void;
onImportDlc: () => void;
onImportFile: () => void;
onExportQueue: () => void;
onSubmit: () => void;
onQueryChange: (value: string) => void;
onSelectionChange: (rowId: string) => void;
onRemoveSelected: () => void;
}
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
export interface CollectorViewProps {
model: CollectorViewModel;
actions: CollectorViewActions;
region?: CollectorViewRegion;
}
export interface CollectorInputDialogProps {
open: boolean;
tabName: string;
value: string;
onChange: (value: string) => void;
onClose: () => void;
onCommit: () => void;
}
import { SlidingSelection } from "../../ui/SlidingSelection";
import type { CollectorViewModel } from "./collector-model";
import "./collector.css";
export interface CollectorViewActions {
onTabSelect: (tabId: string) => void;
onTabAdd: () => void;
onTabRemove: (tabId: string) => void;
onOpenInput: () => void;
onImportDlc: () => void;
onImportFile: () => void;
onExportQueue: () => void;
onSubmit: () => void;
onQueryChange: (value: string) => void;
onSelectionChange: (rowId: string) => void;
onRemoveSelected: () => void;
}
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
export interface CollectorViewProps {
model: CollectorViewModel;
actions: CollectorViewActions;
region?: CollectorViewRegion;
}
export interface CollectorInputDialogProps {
open: boolean;
tabName: string;
value: string;
onChange: (value: string) => void;
onClose: () => void;
onCommit: () => void;
}
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
return (
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
<div className="collector-sidebar-heading">
<strong>Sammlungen</strong>
<span>{model.tabs.length}</span>
</div>
<div className="collector-sidebar-list">
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
<div className="collector-sidebar-heading">
<strong>Sammlungen</strong>
<span>{model.tabs.length}</span>
</div>
<SlidingSelection activeKey={model.activeTabId} axis="vertical" className="collector-sidebar-list">
{model.tabs.map((tab) => (
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} key={tab.id}>
<button
aria-current={tab.id === model.activeTabId ? "page" : undefined}
className="collector-sidebar-select"
onClick={() => actions.onTabSelect(tab.id)}
type="button"
>
<span>{tab.name}</span>
<span className="collector-sidebar-count">{tab.linkCount}</span>
</button>
{model.tabs.length > 1 ? (
<button
aria-label={`${tab.name} entfernen`}
className="collector-sidebar-remove"
onClick={() => actions.onTabRemove(tab.id)}
type="button"
>×</button>
) : null}
</div>
))}
</div>
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
</div>
);
}
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
<ToolbarGroup label="Links erfassen">
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
</ToolbarGroup>
<ToolbarGroup label="Sammlung verarbeiten">
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
</ToolbarGroup>
<ToolbarSearch
label="Links durchsuchen"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Links durchsuchen"
value={model.query}
/>
</Toolbar>
);
}
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
const selected = new Set(model.selectedIds);
return (
<section className="collector-content" aria-label="Gesammelte Links">
<DataTable className="collector-table" label="Gesammelte Links">
<DataTableHeader className="collector-table-header">
<div className="collector-table-header-row" role="row">
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Sammlung</span>
<span role="columnheader">URL oder Rohzeile</span>
<span role="columnheader">Zeile</span>
<span role="columnheader">Status</span>
</div>
</DataTableHeader>
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
{model.busy ? (
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
) : model.error ? (
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
) : model.empty ? (
<DataTableEmpty
data-visual-region="collector-empty-state"
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
/>
) : (
model.rows.map((row) => (
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
<span className="collector-column-select" role="cell">
<input
aria-label="Link auswählen"
checked={selected.has(row.id)}
onChange={() => actions.onSelectionChange(row.id)}
type="checkbox"
/>
</span>
<span className="collector-row-source" role="cell">{row.tabName}</span>
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
<span className="collector-row-status" role="cell">Lokal</span>
</div>
))
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
if (region === "sidebar") {
return <CollectorSidebar actions={actions} model={model} />;
}
if (region === "toolbar") {
return <CollectorToolbar actions={actions} model={model} />;
}
if (region === "content") {
return <CollectorContent actions={actions} model={model} />;
}
return (
<div className="collector-view">
<CollectorSidebar actions={actions} model={model} />
<div className="collector-view-main">
<CollectorToolbar actions={actions} model={model} />
<CollectorContent actions={actions} model={model} />
</div>
</div>
);
}
export function CollectorInputDialog({
open,
tabName,
value,
onChange,
onClose,
onCommit
}: CollectorInputDialogProps): ReactElement | null {
return (
<Dialog
actions={(
<>
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
</>
)}
description={`Links für ${tabName} lokal erfassen.`}
onClose={onClose}
open={open}
size="wide"
title="Links hinzufügen"
>
<label className="collector-input-label">
<span>Links</span>
<textarea
aria-label="Links"
autoFocus
className="collector-input"
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
placeholder="Eine URL oder Rohzeile pro Zeile"
rows={12}
value={value}
/>
</label>
</Dialog>
);
}
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} data-sliding-selection-active={tab.id === model.activeTabId} data-sliding-selection-item="true" key={tab.id}>
<button
aria-current={tab.id === model.activeTabId ? "page" : undefined}
className="collector-sidebar-select"
onClick={() => actions.onTabSelect(tab.id)}
type="button"
>
<span>{tab.name}</span>
<span className="collector-sidebar-count">{tab.linkCount}</span>
</button>
{model.tabs.length > 1 ? (
<button
aria-label={`${tab.name} entfernen`}
className="collector-sidebar-remove"
onClick={() => actions.onTabRemove(tab.id)}
type="button"
>×</button>
) : null}
</div>
))}
</SlidingSelection>
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
</div>
);
}
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
<ToolbarGroup label="Links erfassen">
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
</ToolbarGroup>
<ToolbarGroup label="Sammlung verarbeiten">
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
</ToolbarGroup>
<ToolbarSearch
label="Links durchsuchen"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Links durchsuchen"
value={model.query}
/>
</Toolbar>
);
}
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
const selected = new Set(model.selectedIds);
return (
<section className="collector-content" aria-label="Gesammelte Links">
<DataTable className="collector-table" label="Gesammelte Links">
<DataTableHeader className="collector-table-header">
<div className="collector-table-header-row" role="row">
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Sammlung</span>
<span role="columnheader">URL oder Rohzeile</span>
<span role="columnheader">Zeile</span>
<span role="columnheader">Status</span>
</div>
</DataTableHeader>
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
{model.busy ? (
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
) : model.error ? (
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
) : model.empty ? (
<DataTableEmpty
data-visual-region="collector-empty-state"
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
/>
) : (
model.rows.map((row) => (
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
<span className="collector-column-select" role="cell">
<input
aria-label="Link auswählen"
checked={selected.has(row.id)}
onChange={() => actions.onSelectionChange(row.id)}
type="checkbox"
/>
</span>
<span className="collector-row-source" role="cell">{row.tabName}</span>
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
<span className="collector-row-status" role="cell">Lokal</span>
</div>
))
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
if (region === "sidebar") {
return <CollectorSidebar actions={actions} model={model} />;
}
if (region === "toolbar") {
return <CollectorToolbar actions={actions} model={model} />;
}
if (region === "content") {
return <CollectorContent actions={actions} model={model} />;
}
return (
<div className="collector-view">
<CollectorSidebar actions={actions} model={model} />
<div className="collector-view-main">
<CollectorToolbar actions={actions} model={model} />
<CollectorContent actions={actions} model={model} />
</div>
</div>
);
}
export function CollectorInputDialog({
open,
tabName,
value,
onChange,
onClose,
onCommit
}: CollectorInputDialogProps): ReactElement | null {
return (
<Dialog
actions={(
<>
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
</>
)}
description={`Links für ${tabName} lokal erfassen.`}
onClose={onClose}
open={open}
size="wide"
title="Links hinzufügen"
>
<label className="collector-input-label">
<span>Links</span>
<textarea
aria-label="Links"
autoFocus
className="collector-input"
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
placeholder="Eine URL oder Rohzeile pro Zeile"
rows={12}
value={value}
/>
</label>
</Dialog>
);
}
+306 -306
View File
@@ -1,309 +1,309 @@
.collector-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.collector-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
}
.collector-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
}
.collector-sidebar-heading {
align-items: center;
color: var(--ui-text-secondary);
display: flex;
font-size: 13px;
justify-content: space-between;
min-height: 28px;
}
.collector-sidebar-heading span,
.collector-sidebar-count {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-sidebar-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.collector-sidebar-item {
align-items: stretch;
border: 1px solid transparent;
border-radius: 6px;
display: flex;
min-height: 36px;
}
.collector-sidebar-item:hover {
background: var(--ui-hover);
}
.collector-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.collector-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
}
.collector-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
}
.collector-sidebar-heading {
align-items: center;
color: var(--ui-text-secondary);
display: flex;
font-size: 13px;
justify-content: space-between;
min-height: 28px;
}
.collector-sidebar-heading span,
.collector-sidebar-count {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-sidebar-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.collector-sidebar-item {
align-items: stretch;
border: 1px solid transparent;
border-radius: 6px;
display: flex;
min-height: 36px;
}
.collector-sidebar-item:hover {
background: var(--ui-hover);
}
.collector-sidebar-item.is-active {
background: var(--ui-active);
border-color: var(--ui-border);
}
.collector-sidebar-select,
.collector-sidebar-remove,
.collector-sidebar-add {
background: transparent;
border: 0;
color: var(--ui-text-secondary);
font: inherit;
}
.collector-sidebar-select {
align-items: center;
display: flex;
flex: 1;
gap: 8px;
justify-content: space-between;
min-width: 0;
padding: 0 8px;
text-align: left;
}
.collector-sidebar-select span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-sidebar-remove {
border-left: 1px solid var(--ui-border);
min-width: 32px;
}
.collector-sidebar-add {
border: 1px solid var(--ui-border);
border-radius: 6px;
min-height: 36px;
padding: 0 10px;
text-align: left;
}
.collector-sidebar-add:hover,
.collector-sidebar-remove:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-toolbar {
min-width: 0;
width: 100%;
}
.collector-action {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
height: 36px;
padding: 0 12px;
white-space: nowrap;
}
.collector-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-action-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-action-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: #181A1F;
}
.collector-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.collector-action:disabled {
cursor: default;
opacity: 0.45;
}
.collector-content {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.collector-table {
height: 100%;
}
.collector-table-header-row,
.collector-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
min-width: 760px;
}
.collector-table-header {
height: 41px;
}
.collector-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collector-table-header-row > span,
.collector-row > span {
min-width: 0;
padding: 0 12px;
}
.collector-table-body {
overflow: auto;
}
.collector-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.collector-row:hover {
background: var(--ui-hover);
}
.collector-row.is-selected {
background: var(--ui-active);
}
.collector-column-select {
display: grid;
place-items: center;
}
.collector-row-source {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-row-value {
color: var(--ui-text);
overflow: hidden;
text-overflow: ellipsis;
user-select: text;
white-space: nowrap;
}
.collector-row-line,
.collector-row-status {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.collector-input-label {
display: grid;
gap: 8px;
}
.collector-input-label > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
}
.collector-input {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
font: inherit;
line-height: 1.5;
min-height: 240px;
padding: 12px;
resize: vertical;
user-select: text;
width: 100%;
}
.collector-dialog-primary,
.collector-dialog-secondary {
border: 1px solid var(--ui-border);
border-radius: 6px;
font: inherit;
height: 36px;
padding: 0 14px;
}
.collector-dialog-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-dialog-secondary {
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
@media (max-width: 1366px) {
.collector-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.collector-sidebar {
overflow: hidden;
}
.collector-action {
padding: 0 9px;
}
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
min-width: 660px;
}
}
@media (max-width: 1120px) {
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
min-width: 610px;
}
border-color: transparent;
}
.collector-sidebar-select,
.collector-sidebar-remove,
.collector-sidebar-add {
background: transparent;
border: 0;
color: var(--ui-text-secondary);
font: inherit;
}
.collector-sidebar-select {
align-items: center;
display: flex;
flex: 1;
gap: 8px;
justify-content: space-between;
min-width: 0;
padding: 0 8px;
text-align: left;
}
.collector-sidebar-select span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-sidebar-remove {
border-left: 1px solid var(--ui-border);
min-width: 32px;
}
.collector-sidebar-add {
border: 1px solid var(--ui-border);
border-radius: 6px;
min-height: 36px;
padding: 0 10px;
text-align: left;
}
.collector-sidebar-add:hover,
.collector-sidebar-remove:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-toolbar {
min-width: 0;
width: 100%;
}
.collector-action {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
height: 36px;
padding: 0 12px;
white-space: nowrap;
}
.collector-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-action-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-action-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: #181A1F;
}
.collector-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.collector-action:disabled {
cursor: default;
opacity: 0.45;
}
.collector-content {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.collector-table {
height: 100%;
}
.collector-table-header-row,
.collector-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
min-width: 760px;
}
.collector-table-header {
height: 41px;
}
.collector-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collector-table-header-row > span,
.collector-row > span {
min-width: 0;
padding: 0 12px;
}
.collector-table-body {
overflow: auto;
}
.collector-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.collector-row:hover {
background: var(--ui-hover);
}
.collector-row.is-selected {
background: var(--ui-active);
}
.collector-column-select {
display: grid;
place-items: center;
}
.collector-row-source {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-row-value {
color: var(--ui-text);
overflow: hidden;
text-overflow: ellipsis;
user-select: text;
white-space: nowrap;
}
.collector-row-line,
.collector-row-status {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.collector-input-label {
display: grid;
gap: 8px;
}
.collector-input-label > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
}
.collector-input {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
font: inherit;
line-height: 1.5;
min-height: 240px;
padding: 12px;
resize: vertical;
user-select: text;
width: 100%;
}
.collector-dialog-primary,
.collector-dialog-secondary {
border: 1px solid var(--ui-border);
border-radius: 6px;
font: inherit;
height: 36px;
padding: 0 14px;
}
.collector-dialog-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-dialog-secondary {
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
@media (max-width: 1366px) {
.collector-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.collector-sidebar {
overflow: hidden;
}
.collector-action {
padding: 0 9px;
}
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
min-width: 660px;
}
}
@media (max-width: 1120px) {
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
min-width: 610px;
}
}
+168 -35
View File
@@ -1,10 +1,11 @@
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
import { memo, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
extractHoster,
formatAudioStripSummary,
formatDateTime,
formatHosterLabel,
formatSpeedMbps,
humanSize,
providerLabels
@@ -13,24 +14,77 @@ import type { DownloadPackageRow } from "./downloads-model";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
type HosterLabel = ReturnType<typeof formatHosterLabel>;
function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement {
const [iconFailed, setIconFailed] = useState(false);
if (label.iconSrc && !iconFailed) return <img alt="" className="downloads-hoster-icon" data-hoster={label.title.toLowerCase()} onError={() => setIconFailed(true)} src={label.iconSrc} />;
return <span>{label.compact}</span>;
}
function HosterLabels({ labels }: { labels: HosterLabel[] }): ReactElement {
return (
<span className="downloads-cell downloads-hoster-cell" title={labels.map((label) => label.title).join(", ")}>
{labels.map((label) => <HosterLabelContent key={label.title} label={label} />)}
</span>
);
}
function downloadGridTemplate(gridTemplate: string): string {
return `${DOWNLOAD_SELECTION_COLUMN_WIDTH} ${gridTemplate} ${DOWNLOAD_ACTION_COLUMN_WIDTH}`;
}
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
account: { label: "Service", width: "132px" },
prio: { label: "Priorität", width: "70px" },
status: { label: "Status", width: "160px" },
speed: { label: "Geschwindigkeit", width: "90px" },
added: { label: "Hinzugefügt am", width: "155px" }
name: { label: "Name", width: "minmax(290px, 2.3fr)", sortable: "name" },
size: { label: "Geladen / Größe", width: "minmax(140px, 1.1fr)", sortable: "size" },
progress: { label: "Fortschritt", width: "minmax(105px, 0.85fr)", sortable: "progress" },
hoster: { label: "Hoster", width: "minmax(90px, 0.85fr)", sortable: "hoster" },
account: { label: "Service", width: "minmax(90px, 0.85fr)" },
prio: { label: "Priorität", width: "minmax(85px, 0.8fr)" },
status: { label: "Status", width: "minmax(90px, 0.85fr)" },
speed: { label: "Geschwindigkeit", width: "minmax(120px, 1fr)" },
availability: { label: "Verfügbarkeit", width: "minmax(110px, 1fr)" },
added: { label: "Hinzugefügt am", width: "minmax(135px, 1fr)" }
};
export type AvailabilityState = "online" | "partial" | "offline" | "checking";
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
const total = items.length;
const online = items.filter((item) => item.onlineStatus === "online").length;
const offline = items.filter((item) => item.onlineStatus === "offline").length;
if (total > 0 && online === total) return { online, total, state: "online" };
if (total > 0 && offline === total) return { online, total, state: "offline" };
if (total > 0 && online + offline === total) return { online, total, state: "partial" };
return { online, total, state: "checking" };
}
function Availability({ online, total, state, text }: { online: number; total: number; state: AvailabilityState; text?: string }): ReactElement {
const symbol = state === "online" ? "✓" : state === "offline" ? "×" : "●";
const label = text ?? `${online}/${total} online`;
if (text) {
return <span aria-label={label} className={`downloads-cell downloads-availability is-${state}`} title={label}><span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span><span className="downloads-availability-label">{text}</span></span>;
}
return (
<span aria-label={label} className={`downloads-cell downloads-availability has-counts is-${state}`} title={label}>
<span aria-hidden="true" className="downloads-availability-symbol">{symbol}</span>
<span className="downloads-availability-count is-online-count">{online}</span>
<span aria-hidden="true" className="downloads-availability-separator">/</span>
<span className="downloads-availability-count is-total-count">{total}</span>
<span className="downloads-availability-label">online</span>
</span>
);
}
export interface DownloadsTableActions {
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
onSelectionMouseEnter: (id: string) => void;
onTogglePackage: (packageId: string) => void;
onTogglePackageCollapse: (packageId: string) => void;
onStartPackageRename: (packageId: string, packageName: string) => void;
onPackageRenameChange: (name: string) => void;
@@ -41,11 +95,10 @@ export interface DownloadsTableActions {
onMovePackageDown: (packageId: string) => void;
onRemoveItem: (itemId: string) => void;
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
onColumnDragStart: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragOver: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragLeave: () => void;
onColumnDrop: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragEnd: () => void;
onColumnPointerDown: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnPointerMove: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnPointerUp: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnPointerCancel: (column: string, event: ReactPointerEvent<HTMLDivElement>) => void;
onColumnContextMenu: (column: string, x: number, y: number) => void;
onSortColumn: (column: DownloadSortColumn) => void;
}
@@ -85,12 +138,18 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
}
if (column === "hoster") {
const hoster = extractHoster(item.url);
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
const label = formatHosterLabel(hoster);
return <HosterLabels labels={[label]} />;
}
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
if (column === "prio") return <span className="downloads-cell" />;
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
if (column === "availability") {
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking";
const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft";
return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />;
}
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
return null;
}
@@ -110,7 +169,7 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
className={`downloads-item-row${selected ? " is-selected" : ""}`}
data-download-row-id={item.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
event.stopPropagation();
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
@@ -126,8 +185,8 @@ export function ItemRowContent({ item, selected, sessionRunning = true, columnOr
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
}}
>
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => actions.onToggleSelection(item.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(item.id, true, event.shiftKey); }} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button"></button></span>
</div>
);
@@ -163,6 +222,71 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
interface PackageItemsTransitionProps {
actions: DownloadsTableActions;
collapsed: boolean;
columnOrder: readonly string[];
gridTemplate: string;
items: DownloadItem[];
selectedIds: ReadonlySet<string>;
sessionRunning: boolean;
}
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
const [renderItems, setRenderItems] = useState(!collapsed);
const animationRef = useRef<Animation | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const initialRenderRef = useRef(true);
useRendererLayoutEffect(() => {
if (initialRenderRef.current) {
initialRenderRef.current = false;
return;
}
if (!renderItems) {
if (!collapsed) setRenderItems(true);
return;
}
const container = containerRef.current;
const inner = innerRef.current;
if (!container || !inner) return;
animationRef.current?.cancel();
const targetHeight = inner.scrollHeight;
const animation = collapsed
? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" })
: container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" });
animationRef.current = animation;
animation.onfinish = () => {
if (animationRef.current !== animation) return;
animationRef.current = null;
if (collapsed) {
setRenderItems(false);
} else {
animation.cancel();
}
};
return () => {
animation.onfinish = null;
animation.cancel();
if (animationRef.current === animation) animationRef.current = null;
};
}, [collapsed, renderItems]);
if (!renderItems) return null;
return (
<div
aria-hidden={collapsed}
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
ref={containerRef}
>
<div className="downloads-package-items-inner" ref={innerRef}>
{items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
</div>
</div>
);
}
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
let done = 0;
let failed = 0;
@@ -204,7 +328,6 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
return (
<span className="downloads-cell downloads-name-cell">
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
<input aria-label={`${entry.name} aktivieren`} checked={entry.enabled} onChange={() => actions.onTogglePackage(entry.id)} onClick={(event) => event.stopPropagation()} type="checkbox" />
{editing
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
@@ -228,8 +351,8 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
}
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
if (column === "hoster") {
const value = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
return <span className="downloads-cell" title={value}>{value}</span>;
const labels = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].map(formatHosterLabel);
return <HosterLabels labels={labels} />;
}
if (column === "account") {
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
@@ -241,6 +364,10 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
if (column === "availability") {
const availability = getAvailabilitySummary(row.items);
return <Availability {...availability} />;
}
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
return null;
}
@@ -272,7 +399,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
};
return (
<article
className={`package-card downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
className={`downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
data-download-package-id={entry.id}
draggable={draggable}
onContextMenu={(event) => {
@@ -289,7 +416,7 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
className="downloads-package-row"
data-download-row-id={entry.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
@@ -302,11 +429,11 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
>
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => actions.onToggleSelection(entry.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => {}} onClick={(event) => { event.stopPropagation(); actions.onToggleSelection(entry.id, true, event.shiftKey); }} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
{!row.collapsed && row.items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
<PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} />
</article>
);
}
@@ -345,7 +472,7 @@ export interface DownloadsTableHeaderProps {
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
return (
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}>
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: downloadGridTemplate(gridTemplate) }}>
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
{columnOrder.map((column) => {
const definition = downloadColumnDefinitions[column];
@@ -353,14 +480,20 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
return (
<div
className="downloads-column-header"
draggable
data-download-column={column}
key={column}
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
onDragEnd={actions.onColumnDragEnd}
onDragLeave={actions.onColumnDragLeave}
onDragOver={(event) => actions.onColumnDragOver(column, event)}
onDragStart={(event) => actions.onColumnDragStart(column, event)}
onDrop={(event) => actions.onColumnDrop(column, event)}
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)}
onPointerDown={(event) => {
if (event.button !== 0 || !event.isPrimary) return;
event.currentTarget.setPointerCapture(event.pointerId);
actions.onColumnPointerDown(column, event);
}}
onPointerMove={(event) => actions.onColumnPointerMove(column, event)}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
actions.onColumnPointerUp(column, event);
}}
role="columnheader"
>
{definition.sortable
+185 -184
View File
@@ -1,197 +1,198 @@
import type { ReactElement } from "react";
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
import {
DownloadsTableHeader,
ItemRow,
PackageCard,
type DownloadSortColumn,
type DownloadsTableActions
} from "./DownloadsTable";
import "./downloads.css";
export interface DownloadsStatusModel {
packages: number;
links: number;
import { RollingMetricValue } from "../../ui/RollingMetricValue";
import { SlidingSelection } from "../../ui/SlidingSelection";
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
import {
DownloadsTableHeader,
ItemRow,
PackageCard,
type DownloadSortColumn,
type DownloadsTableActions
} from "./DownloadsTable";
import "./downloads.css";
export interface DownloadsStatusModel {
packages: number;
links: number;
session: string;
sessionBytes: number;
total: string;
hosters: number;
speed: string;
eta: string;
}
export interface DownloadsViewModel extends DownloadsViewModelCore {
running: boolean;
paused: boolean;
canStart: boolean;
canPause: boolean;
canStop: boolean;
actionBusy: boolean;
reconnectSeconds: number;
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
columnOrder: readonly string[];
gridTemplate: string;
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
status: DownloadsStatusModel;
}
export interface DownloadsViewActions extends DownloadsTableActions {
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
onFilterChange: (filter: DownloadSidebarFilter) => void;
onProviderFilterChange: (provider: string) => void;
onQueryChange: (query: string) => void;
onAddLinks: () => void;
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
onRenameSelection: () => void;
onRemoveSelection: () => void;
onToggleClipboardWatcher: () => void;
onClearAll: () => void;
onToggleAllPackages: () => void;
onShowAllPackages: () => void;
onPackageDragStart: (packageId: string) => void;
onPackageDrop: (packageId: string) => void;
onPackageDragEnd: () => void;
}
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
{ id: "all", label: "Alle" },
{ id: "active", label: "Aktiv" },
{ id: "queued", label: "Wartend" },
{ id: "paused", label: "Pausiert" },
{ id: "completed", label: "Fertig" },
{ id: "failed", label: "Fehler" }
];
totalBytes: number;
hosters: number;
speed: string;
eta: string;
}
export interface DownloadsViewModel extends DownloadsViewModelCore {
running: boolean;
paused: boolean;
canStart: boolean;
canPause: boolean;
canStop: boolean;
actionBusy: boolean;
reconnectSeconds: number;
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
columnOrder: readonly string[];
gridTemplate: string;
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
status: DownloadsStatusModel;
}
export interface DownloadsViewActions extends DownloadsTableActions {
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
onFilterChange: (filter: DownloadSidebarFilter) => void;
onProviderFilterChange: (provider: string) => void;
onQueryChange: (query: string) => void;
onAddLinks: () => void;
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
onRenameSelection: () => void;
onRemoveSelection: () => void;
onToggleClipboardWatcher: () => void;
onClearAll: () => void;
onToggleAllPackages: () => void;
onShowAllPackages: () => void;
onPackageDragStart: (packageId: string) => void;
onPackageDrop: (packageId: string) => void;
onPackageDragEnd: () => void;
}
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
{ id: "all", label: "Alle" },
{ id: "active", label: "Aktiv" },
{ id: "queued", label: "Wartend" },
{ id: "paused", label: "Pausiert" },
{ id: "completed", label: "Fertig" },
{ id: "failed", label: "Fehler" }
];
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<div className="downloads-mode-switch" role="group" aria-label="Downloadansicht">
<button className={model.displayMode === "packages" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("packages")} type="button">Pakete</button>
<button className={model.displayMode === "files" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("files")} type="button">Dateien</button>
</div>
<nav aria-label="Downloadfilter">
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
</nav>
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<div className="downloads-mode-title">Pakete</div>
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical">
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
</SlidingSelection>
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
<div className="downloads-sidebar-actions">
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
<label><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
</div>
</aside>
);
}
<label className="downloads-clipboard-toggle"><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
</aside>
);
}
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
const entries = [
["Pakete", String(model.status.packages)],
["Links", String(model.status.links)],
["Sitzung", model.status.session],
["Gesamt", model.status.total],
["Hoster", String(model.status.hosters)],
["Geschwindigkeit", model.status.speed],
["ETA", model.status.eta]
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) },
{ label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) },
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) }
];
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
<span className="downloads-toolbar-divider" />
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
</div>
);
}
function tableState(model: DownloadsViewModel): ReactElement | null {
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
return null;
}
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
return model.packageRows.map((row: DownloadPackageRow) => (
<PackageCard
actions={actions}
columnOrder={model.columnOrder}
editing={model.editingPackageId === row.package.id}
editingName={model.editingName}
gridTemplate={model.gridTemplate}
key={row.package.id}
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
onDragEnd={actions.onPackageDragEnd}
onDragStart={actions.onPackageDragStart}
onDrop={actions.onPackageDrop}
row={row}
selectedIds={model.selectedIds}
selectedVersion={model.actionableSelectedIds.length}
sessionRunning={model.running}
/>
));
}
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<main className="downloads-content">
<div className="downloads-table" role="table" aria-label="Downloads">
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
{tableState(model)}
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
</div>
</div>
</main>
);
}
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<footer className="downloads-footer" data-visual-region="downloads-pagination">
<span>{model.paginationLabel}</span>
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
</footer>
);
}
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<div className="downloads-view">
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
</div>
);
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
<span className="downloads-toolbar-divider" />
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
</div>
);
}
function tableState(model: DownloadsViewModel): ReactElement | null {
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
return null;
}
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
return model.packageRows.map((row: DownloadPackageRow) => (
<PackageCard
actions={actions}
columnOrder={model.columnOrder}
editing={model.editingPackageId === row.package.id}
editingName={model.editingName}
gridTemplate={model.gridTemplate}
key={row.package.id}
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
onDragEnd={actions.onPackageDragEnd}
onDragStart={actions.onPackageDragStart}
onDrop={actions.onPackageDrop}
row={row}
selectedIds={model.selectedIds}
selectedVersion={model.actionableSelectedIds.length}
sessionRunning={model.running}
/>
));
}
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<main className="downloads-content">
<div className="downloads-table" role="table" aria-label="Downloads">
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
{tableState(model)}
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
</div>
</div>
</main>
);
}
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<footer className="downloads-footer" data-visual-region="downloads-pagination">
<span>{model.paginationLabel}</span>
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
</footer>
);
}
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<div className="downloads-view">
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
export interface ColumnDragMeasurement {
id: string;
left: number;
width: number;
}
export interface ColumnDragPreview {
order: string[];
offsets: Record<string, number>;
settleOffsets: Record<string, number>;
}
export interface DownloadColumnDragSession {
active: boolean;
draggedId: string;
measurements: ColumnDragMeasurement[];
pointerId: number;
preview: ColumnDragPreview;
root: HTMLElement;
startX: number;
}
export function calculateColumnDragPreview(columns: readonly ColumnDragMeasurement[], draggedId: string, deltaX: number): ColumnDragPreview {
const dragged = columns.find((column) => column.id === draggedId);
if (!dragged) {
const order = columns.map((column) => column.id);
const offsets = Object.fromEntries(order.map((id) => [id, 0]));
return { order, offsets, settleOffsets: { ...offsets } };
}
const draggedCenter = dragged.left + dragged.width / 2 + deltaX;
const remaining = columns.filter((column) => column.id !== draggedId);
let targetIndex = remaining.findIndex((column) => draggedCenter < column.left + column.width / 2);
if (targetIndex < 0) targetIndex = remaining.length;
const order = remaining.map((column) => column.id);
order.splice(targetIndex, 0, draggedId);
const originalById = new Map(columns.map((column) => [column.id, column]));
const targetLeftById = new Map<string, number>();
let nextLeft = columns[0]?.left ?? 0;
for (const id of order) {
const column = originalById.get(id);
if (!column) continue;
targetLeftById.set(id, nextLeft);
nextLeft += column.width;
}
const settleOffsets = Object.fromEntries(columns.map((column) => [column.id, (targetLeftById.get(column.id) ?? column.left) - column.left]));
const offsets = { ...settleOffsets, [draggedId]: deltaX };
return { order, offsets, settleOffsets };
}
function setColumnOffsets(root: HTMLElement, offsets: Record<string, number>, excludedId?: string): void {
for (const [id, offset] of Object.entries(offsets)) {
if (id === excludedId) continue;
root.style.setProperty(`--downloads-column-drag-${id}`, `${offset}px`);
}
}
function setDraggedColumn(root: HTMLElement, draggedId: string, dragging: boolean): void {
if (dragging && root.dataset.columnDragging !== draggedId) {
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
root.querySelectorAll<HTMLElement>(`[data-download-column="${draggedId}"]`).forEach((element) => { element.dataset.columnDragging = "true"; });
root.dataset.columnDragging = draggedId;
} else if (!dragging && root.dataset.columnDragging) {
root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
delete root.dataset.columnDragging;
}
}
export function beginDownloadColumnDrag(element: HTMLElement, draggedId: string, pointerId: number, clientX: number): DownloadColumnDragSession | null {
const root = element.closest<HTMLElement>(".downloads-table");
const header = element.closest<HTMLElement>(".downloads-table-header");
if (!root || !header) return null;
const measurements = Array.from(header.querySelectorAll<HTMLElement>(".downloads-column-header[data-download-column]")).map((column) => {
const rect = column.getBoundingClientRect();
return { id: column.dataset.downloadColumn ?? "", left: rect.left, width: rect.width };
}).filter((column) => column.id && column.width > 0);
if (!measurements.some((column) => column.id === draggedId)) return null;
return {
active: false,
draggedId,
measurements,
pointerId,
preview: calculateColumnDragPreview(measurements, draggedId, 0),
root,
startX: clientX
};
}
export function updateDownloadColumnDrag(session: DownloadColumnDragSession, clientX: number): boolean {
const deltaX = clientX - session.startX;
if (!session.active && Math.abs(deltaX) < 5) return false;
if (!session.active) {
session.active = true;
session.root.classList.add("is-column-drag-active");
setDraggedColumn(session.root, session.draggedId, true);
}
const nextPreview = calculateColumnDragPreview(session.measurements, session.draggedId, deltaX);
session.root.style.setProperty("--downloads-active-drag-x", `${deltaX}px`);
if (nextPreview.order.join("|") !== session.preview.order.join("|")) {
setColumnOffsets(session.root, nextPreview.offsets, session.draggedId);
}
session.preview = nextPreview;
return true;
}
export function settleDownloadColumnDrag(session: DownloadColumnDragSession, cancelled: boolean): string[] | null {
if (!session.active) return null;
const originalOrder = session.measurements.map((column) => column.id);
const order = cancelled ? originalOrder : session.preview.order;
const offsets = cancelled
? Object.fromEntries(originalOrder.map((id) => [id, 0]))
: session.preview.settleOffsets;
setColumnOffsets(session.root, offsets);
session.root.style.removeProperty("--downloads-active-drag-x");
setDraggedColumn(session.root, session.draggedId, false);
session.root.classList.add("is-column-drag-settling");
return order;
}
export function clearDownloadColumnDrag(session: DownloadColumnDragSession): void {
session.root.classList.remove("is-column-drag-active", "is-column-drag-settling");
session.root.style.removeProperty("--downloads-active-drag-x");
session.measurements.forEach((measurement) => session.root.style.removeProperty(`--downloads-column-drag-${measurement.id}`));
session.root.querySelectorAll<HTMLElement>("[data-column-dragging]").forEach((element) => delete element.dataset.columnDragging);
delete session.root.dataset.columnDragging;
}
+188 -180
View File
@@ -1,186 +1,194 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
export type DownloadDisplayMode = "packages" | "files";
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
export interface DownloadsModelInput {
packageOrder: string[];
packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>;
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
query: string;
collapsedPackageIds: Iterable<string>;
selectedIds: Iterable<string>;
hideExtractedItems: boolean;
showAllPackages: boolean;
renderLimit: number;
}
export interface DownloadFilterCounts {
all: number;
active: number;
queued: number;
paused: number;
completed: number;
failed: number;
}
export interface DownloadPackageRow {
package: PackageEntry;
items: DownloadItem[];
collapsed: boolean;
}
export interface DownloadsViewModelCore {
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
providerOptions: Array<{ id: string; label: string }>;
query: string;
counts: DownloadFilterCounts;
packageRows: DownloadPackageRow[];
fileRows: DownloadItem[];
visibleItemIds: string[];
visibleRowIds: string[];
actionableSelectedIds: string[];
actionableSelectedPackageIds: string[];
selectedIds: Set<string>;
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
limited: boolean;
empty: boolean;
filteredEmpty: boolean;
}
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
if (activeStatuses.has(status)) return "active";
if (queuedStatuses.has(status)) return "queued";
if (status === "paused" || status === "completed" || status === "failed") return status;
return "all";
}
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
export type DownloadDisplayMode = "packages" | "files";
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
export interface DownloadsModelInput {
packageOrder: string[];
packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>;
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
query: string;
collapsedPackageIds: Iterable<string>;
selectedIds: Iterable<string>;
hideExtractedItems: boolean;
showAllPackages: boolean;
renderLimit: number;
}
export interface DownloadFilterCounts {
all: number;
active: number;
queued: number;
paused: number;
completed: number;
failed: number;
}
export interface DownloadPackageRow {
package: PackageEntry;
items: DownloadItem[];
collapsed: boolean;
}
export interface DownloadsViewModelCore {
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
providerOptions: Array<{ id: string; label: string }>;
query: string;
counts: DownloadFilterCounts;
packageRows: DownloadPackageRow[];
fileRows: DownloadItem[];
visibleItemIds: string[];
visibleRowIds: string[];
actionableSelectedIds: string[];
actionableSelectedPackageIds: string[];
selectedIds: Set<string>;
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
limited: boolean;
empty: boolean;
filteredEmpty: boolean;
}
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
if (activeStatuses.has(status)) return "active";
if (queuedStatuses.has(status)) return "queued";
if (status === "paused" || status === "completed" || status === "failed") return status;
return "all";
}
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const item of items) {
counts.all += 1;
const category = classifyDownloadStatus(item.status);
if (category !== "all") counts[category] += 1;
}
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const item of items) {
counts.all += 1;
const category = classifyDownloadStatus(item.status);
if (category !== "all") counts[category] += 1;
}
return counts;
}
function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadStatus(item.status) === filter;
}
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
return providerFilter === "all" || item.provider === providerFilter;
}
function isActivePackage(row: DownloadPackageRow): boolean {
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
}
function paginationLabel(visible: number, total: number): string {
if (visible === 0 || total === 0) return "0 von 0";
return `1${visible} von ${total}`;
}
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
const allPackages = input.packageOrder
.map((id) => input.packages[id])
.filter((entry): entry is PackageEntry => Boolean(entry));
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
const counts = buildDownloadSidebarCounts(allItems);
const providerMap = new Map<string, string>();
for (const entry of allItems) {
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
let total = 0;
for (const item of items) {
total += item.totalBytes || item.downloadedBytes || 0;
}
const query = input.query.trim().toLocaleLowerCase("de-DE");
const collapsed = new Set(input.collapsedPackageIds);
const selectedIds = new Set(input.selectedIds);
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
const items = entry.itemIds
.map((id) => input.items[id])
.filter((item): item is DownloadItem => Boolean(item))
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
const matchingItems = items.filter((item) => {
const itemMatchesQuery = query === ""
|| matchesQuery(item.fileName, query)
|| matchesQuery(item.targetPath, query)
|| matchesQuery(item.providerLabel, query)
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
if (matchingItems.length === 0) return [];
const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
const activeRows = packageRows.filter(isActivePackage);
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
}
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
const visibleItemIds = (input.displayMode === "files"
? fileRows
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
const visibleRowIds = input.displayMode === "files"
? visibleItemIds
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
const visibleRowSet = new Set(visibleRowIds);
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
const totalMainRowCount = input.displayMode === "files"
? fileRows.length
: totalPackageRows;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter: input.providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
packageRows: displayedPackages,
fileRows,
visibleItemIds,
visibleRowIds,
actionableSelectedIds,
actionableSelectedPackageIds,
selectedIds,
mainRowCount,
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: mainRowCount < totalMainRowCount,
empty: allItems.length === 0,
filteredEmpty: allItems.length > 0 && mainRowCount === 0
};
return total;
}
function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadStatus(item.status) === filter;
}
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
return providerFilter === "all" || item.provider === providerFilter;
}
function isActivePackage(row: DownloadPackageRow): boolean {
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
}
function paginationLabel(visible: number, total: number): string {
if (visible === 0 || total === 0) return "0 von 0";
return `1${visible} von ${total}`;
}
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
const allPackages = input.packageOrder
.map((id) => input.packages[id])
.filter((entry): entry is PackageEntry => Boolean(entry));
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
const counts = buildDownloadSidebarCounts(allItems);
const providerMap = new Map<string, string>();
for (const entry of allItems) {
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
const query = input.query.trim().toLocaleLowerCase("de-DE");
const collapsed = new Set(input.collapsedPackageIds);
const selectedIds = new Set(input.selectedIds);
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
const items = entry.itemIds
.map((id) => input.items[id])
.filter((item): item is DownloadItem => Boolean(item))
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
const matchingItems = items.filter((item) => {
const itemMatchesQuery = query === ""
|| matchesQuery(item.fileName, query)
|| matchesQuery(item.targetPath, query)
|| matchesQuery(item.providerLabel, query)
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
if (matchingItems.length === 0) return [];
const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
const activeRows = packageRows.filter(isActivePackage);
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
}
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
const visibleItemIds = (input.displayMode === "files"
? fileRows
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
const visibleRowIds = input.displayMode === "files"
? visibleItemIds
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
const visibleRowSet = new Set(visibleRowIds);
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
const totalMainRowCount = input.displayMode === "files"
? fileRows.length
: totalPackageRows;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter: input.providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
packageRows: displayedPackages,
fileRows,
visibleItemIds,
visibleRowIds,
actionableSelectedIds,
actionableSelectedPackageIds,
selectedIds,
mainRowCount,
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: mainRowCount < totalMainRowCount,
empty: allItems.length === 0,
filteredEmpty: allItems.length > 0 && mainRowCount === 0
};
}
+301 -30
View File
@@ -34,43 +34,51 @@
flex: 1;
flex-direction: column;
gap: 14px;
height: 100%;
min-height: 0;
padding: 14px 12px;
padding: 14px 12px 6px;
}
.downloads-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 2px;
.downloads-mode-title {
display: flex;
min-height: 36px;
align-items: center;
padding: 0 10px;
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
background: var(--ui-active);
font-weight: 700;
}
.downloads-mode-switch button,
.downloads-sidebar nav button,
.downloads-sidebar-actions button {
min-height: 36px;
border: 0;
border: 1px solid transparent;
border-radius: 4px;
color: var(--ui-text);
background: transparent;
text-align: left;
}
.downloads-mode-switch button {
text-align: center;
.downloads-sidebar nav button:not(.is-active):hover,
.downloads-sidebar-actions button:hover:not(:disabled),
.downloads-clipboard-toggle:hover {
border-color: var(--ui-border);
background: var(--ui-hover);
}
.downloads-mode-switch button.is-active,
.downloads-sidebar nav button.is-active {
color: #ffffff;
background: var(--ui-accent);
background: transparent;
}
.downloads-sidebar nav {
display: grid;
gap: 3px;
--ui-sliding-selection-background: var(--ui-accent);
--ui-sliding-selection-border: transparent;
--ui-sliding-selection-radius: 4px;
}
.downloads-sidebar nav button {
@@ -103,15 +111,31 @@
.downloads-sidebar-actions {
display: grid;
gap: 5px;
margin-top: auto;
}
.downloads-sidebar-actions label {
.downloads-sidebar-actions button {
border: 1px solid var(--ui-border);
background: var(--ui-input);
padding: 0 10px;
cursor: pointer;
}
.downloads-sidebar-actions button:disabled {
opacity: 0.45;
cursor: default;
}
.downloads-clipboard-toggle {
display: flex;
align-items: center;
gap: 8px;
margin-top: auto;
min-height: 36px;
padding: 0 8px;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-input);
white-space: nowrap;
}
.downloads-sidebar-status {
@@ -135,6 +159,23 @@
text-align: right;
}
.downloads-rolling-value {
display: inline-grid;
height: 1.35em;
min-width: 0;
align-items: center;
justify-items: end;
overflow: hidden;
font-variant-numeric: tabular-nums;
line-height: 1.35;
}
.downloads-rolling-value-layer {
grid-area: 1 / 1;
min-width: 0;
white-space: nowrap;
}
.downloads-main-column {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
@@ -211,6 +252,14 @@
min-width: 0;
overflow-x: auto;
overflow-y: auto;
scrollbar-gutter: stable;
}
.downloads-table-header,
.downloads-table-body {
box-sizing: border-box;
width: 100%;
min-width: 1191px;
}
.downloads-table-header {
@@ -220,9 +269,8 @@
position: sticky;
top: 0;
z-index: 2;
min-width: max-content;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-muted);
color: var(--ui-text);
background: var(--ui-table-header);
font-size: 11px;
font-weight: 700;
@@ -230,54 +278,113 @@
}
.downloads-table-body {
min-width: max-content;
overflow: visible;
contain: layout style;
}
.downloads-package-card {
min-width: max-content;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 0;
border-bottom: 1px solid var(--ui-border);
border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 72%, transparent);
border-radius: 0;
padding: 0;
background: var(--ui-canvas);
background: transparent;
box-shadow: none;
}
.downloads-package-card.is-disabled {
opacity: 0.58;
}
.downloads-package-items {
height: auto;
overflow: hidden;
opacity: 1;
}
.downloads-package-items.is-collapsed {
height: 0;
opacity: 0;
pointer-events: none;
}
.downloads-package-items-inner {
min-height: 0;
}
.downloads-view :where(button, input, select):focus-visible {
outline: 1px solid #7B8492;
outline-offset: 1px;
}
.downloads-item-row,
.downloads-package-row {
display: grid;
align-items: center;
height: 48px;
min-width: max-content;
height: 40px;
box-sizing: border-box;
width: 100%;
min-width: 0;
color: var(--ui-text);
background: var(--ui-canvas);
background: transparent;
border-color: var(--ui-border);
contain: layout paint style;
content-visibility: auto;
contain-intrinsic-size: 40px;
}
.downloads-item-row {
height: 38px;
contain-intrinsic-size: 38px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
}
.downloads-item-row.is-selected,
.downloads-package-card.is-selected > .downloads-package-row {
background: var(--ui-active);
background: color-mix(in srgb, var(--ui-success) 14%, var(--ui-canvas));
box-shadow: inset 3px 0 0 var(--ui-success);
}
.downloads-package-row:hover,
.downloads-item-row:hover {
background: var(--ui-hover);
}
.downloads-item-row.is-selected:hover,
.downloads-package-card.is-selected > .downloads-package-row:hover {
background: color-mix(in srgb, var(--ui-success) 18%, var(--ui-canvas));
}
.downloads-cell-slot {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-width: 0;
}
.downloads-cell-slot > .downloads-cell {
justify-content: center;
width: 100%;
height: 100%;
text-align: center;
}
.downloads-cell-slot[data-download-column="name"] > .downloads-name-cell {
justify-content: flex-start;
text-align: left;
}
.downloads-cell,
.downloads-name-cell {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 0 9px;
padding: 0 7px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
@@ -298,20 +405,117 @@
min-width: 0;
}
.downloads-action-cell button,
.downloads-collapse-button {
.downloads-hoster-cell {
gap: 6px;
}
.downloads-hoster-icon {
display: block;
flex: 0 0 18px;
width: 18px;
height: 18px;
object-fit: contain;
}
.downloads-hoster-icon[data-hoster="rapidgator"] {
transform: translateY(-4px) scale(2);
}
.downloads-selection-cell input[type="checkbox"] {
box-sizing: border-box;
width: 18px;
height: 18px;
margin: 0;
accent-color: var(--ui-success);
cursor: pointer;
}
.downloads-action-cell button {
box-sizing: border-box;
width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
}
.downloads-collapse-button {
box-sizing: border-box;
flex: 0 0 30px;
width: 30px;
min-width: 30px;
max-width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.downloads-name-cell > input[type="checkbox"] {
box-sizing: border-box;
flex: 0 0 14px;
width: 14px;
height: 14px;
margin: 0;
}
.downloads-column-header {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
padding: 0 9px;
overflow: hidden;
white-space: nowrap;
text-align: center;
text-overflow: ellipsis;
touch-action: none;
cursor: grab;
}
.downloads-table.is-column-drag-active [data-download-column] {
position: relative;
transform: translate3d(var(--downloads-column-drag-x, 0px), 0, 0);
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: transform;
}
.downloads-table [data-download-column="name"] { --downloads-column-drag-x: var(--downloads-column-drag-name, 0px); }
.downloads-table [data-download-column="size"] { --downloads-column-drag-x: var(--downloads-column-drag-size, 0px); }
.downloads-table [data-download-column="progress"] { --downloads-column-drag-x: var(--downloads-column-drag-progress, 0px); }
.downloads-table [data-download-column="hoster"] { --downloads-column-drag-x: var(--downloads-column-drag-hoster, 0px); }
.downloads-table [data-download-column="account"] { --downloads-column-drag-x: var(--downloads-column-drag-account, 0px); }
.downloads-table [data-download-column="prio"] { --downloads-column-drag-x: var(--downloads-column-drag-prio, 0px); }
.downloads-table [data-download-column="status"] { --downloads-column-drag-x: var(--downloads-column-drag-status, 0px); }
.downloads-table [data-download-column="speed"] { --downloads-column-drag-x: var(--downloads-column-drag-speed, 0px); }
.downloads-table [data-download-column="availability"] { --downloads-column-drag-x: var(--downloads-column-drag-availability, 0px); }
.downloads-table [data-download-column="added"] { --downloads-column-drag-x: var(--downloads-column-drag-added, 0px); }
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
--downloads-column-drag-x: var(--downloads-active-drag-x, 0px);
z-index: 4;
transition: none;
cursor: grabbing;
}
.downloads-table.is-column-drag-active .downloads-column-header[data-column-dragging="true"] {
box-shadow: inset 2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent), inset -2px 0 0 color-mix(in srgb, var(--ui-text-muted) 58%, transparent);
}
.downloads-table.is-column-drag-settling [data-download-column] {
transition: transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
}
@media (prefers-reduced-motion: reduce) {
.downloads-table.is-column-drag-active [data-download-column],
.downloads-table.is-column-drag-settling [data-download-column] {
transition-duration: 220ms !important;
}
.downloads-table.is-column-drag-active [data-column-dragging="true"] {
transition-duration: 0s !important;
}
}
.downloads-column-header button {
@@ -319,10 +523,19 @@
border: 0;
padding: 0;
background: transparent;
text-align: left;
text-align: center;
text-transform: inherit;
}
.downloads-column-header[data-download-column="name"] {
justify-content: flex-start;
text-align: left;
}
.downloads-column-header[data-download-column="name"] button {
text-align: left;
}
.downloads-link-state {
flex: 0 0 8px;
width: 8px;
@@ -343,11 +556,65 @@
background: var(--ui-warning);
}
.downloads-availability {
display: grid;
grid-template-columns: 16px 7ch;
gap: 0;
justify-content: center;
font-weight: 700;
}
.downloads-availability.has-counts {
display: grid;
grid-template-columns: 16px 4ch 1ch 4ch auto;
font-variant-numeric: tabular-nums;
}
.downloads-availability-symbol {
width: 16px;
font-size: 15px;
line-height: 1;
text-align: center;
}
.downloads-availability-count.is-online-count {
text-align: right;
}
.downloads-availability-count.is-total-count {
text-align: left;
}
.downloads-availability-separator {
text-align: center;
}
.downloads-availability-label {
padding-left: 4px;
text-align: left;
}
.downloads-availability.is-online {
color: var(--ui-success);
}
.downloads-availability.is-partial {
color: var(--ui-warning);
}
.downloads-availability.is-offline {
color: var(--ui-danger);
}
.downloads-availability.is-checking {
color: var(--ui-text-muted);
}
.downloads-meter {
position: relative;
display: block;
width: 100%;
height: 24px;
height: 20px;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 4px;
@@ -357,7 +624,7 @@
.downloads-meter > span {
position: absolute;
inset: 0 auto 0 0;
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
background: var(--ui-success);
}
.downloads-meter > b {
@@ -371,6 +638,10 @@
font-weight: 600;
}
.downloads-item-row .downloads-meter > b {
color: var(--ui-text);
}
.downloads-empty-state,
.downloads-table-message {
display: flex;
+232 -228
View File
@@ -1,239 +1,243 @@
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableFooter,
DataTableHeader
} from "../../ui/DataTable";
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableFooter,
DataTableHeader
} from "../../ui/DataTable";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
import "./history.css";
export interface HistoryViewActions {
onFilterChange: (filter: HistoryFilter) => void;
onQueryChange: (value: string) => void;
onToggleSelection: (entryId: string) => void;
onToggleSelectAll: (visibleIds: string[]) => void;
onToggleExpansion: (entryId: string) => void;
onRestore: (entryIds: string[]) => void;
onReveal: (entryId: string) => void;
onRemove: (entryIds: string[]) => void;
onClearSelection: () => void;
onClearHistory: () => void;
onContextMenu: (entryId: string, x: number, y: number) => void;
}
export interface HistoryViewProps {
model: HistoryViewModel;
actions: HistoryViewActions;
}
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "all", label: "Alle Einträge" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Letzte 7 Tage" },
{ id: "older", label: "Älter" },
{ id: "completed", label: "Fertig" },
{ id: "deleted", label: "Gelöscht" },
{ id: "failed", label: "Fehlgeschlagen" }
];
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
return (
<div className="history-detail-row" role="row">
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
</div>
</div>
);
}
import { SlidingSelection } from "../../ui/SlidingSelection";
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
import "./history.css";
export interface HistoryViewActions {
onFilterChange: (filter: HistoryFilter) => void;
onQueryChange: (value: string) => void;
onToggleSelection: (entryId: string) => void;
onToggleSelectAll: (visibleIds: string[]) => void;
onToggleExpansion: (entryId: string) => void;
onRestore: (entryIds: string[]) => void;
onReveal: (entryId: string) => void;
onRemove: (entryIds: string[]) => void;
onClearSelection: () => void;
onClearHistory: () => void;
onContextMenu: (entryId: string, x: number, y: number) => void;
}
export interface HistoryViewProps {
model: HistoryViewModel;
actions: HistoryViewActions;
}
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "all", label: "Alle Einträge" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Letzte 7 Tage" },
{ id: "older", label: "Älter" },
{ id: "completed", label: "Fertig" },
{ id: "deleted", label: "Gelöscht" },
{ id: "failed", label: "Fehlgeschlagen" }
];
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
return (
<div className="history-detail-row" role="row">
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
</div>
</div>
);
}
export function HistorySidebar({ model, actions }: HistoryViewProps): ReactElement {
return (
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
<strong className="history-sidebar-heading">Verlauf</strong>
<div className="history-filter-list">
{filterItems.map((item) => (
<button
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
<strong className="history-sidebar-heading">Verlauf</strong>
<SlidingSelection activeKey={model.filter} axis="vertical" className="history-filter-list">
{filterItems.map((item) => (
<button
aria-current={model.filter === item.id ? "page" : undefined}
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
key={item.id}
onClick={() => actions.onFilterChange(item.id)}
type="button"
>
<span>{item.label}</span>
<span>{model.counts[item.id]}</span>
</button>
))}
</div>
<button
className="history-sidebar-clear"
disabled={model.totalCount === 0 || model.loading}
onClick={actions.onClearHistory}
type="button"
>Verlauf leeren</button>
</div>
);
}
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
const selectedIds = model.selectedIds;
const selectedSet = new Set(selectedIds);
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
return (
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
<ToolbarGroup label="Einträge">
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
</ToolbarGroup>
<ToolbarSearch
label="Verlauf durchsuchen"
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
placeholder="Name, Pfad, Hoster oder Provider"
value={model.query}
/>
</Toolbar>
);
}
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
const selected = new Set(model.selectedIds);
const expanded = new Set(model.expandedIds);
const visibleIds = model.rows.map((row) => row.id);
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
? "Noch kein Verlauf"
: "Keine passenden Einträge";
return (
<section aria-label="Verlaufstabelle" className="history-content">
<DataTable className="history-table" label="Verlauf">
<DataTableHeader className="history-table-header">
<div className="history-table-header-row" role="row">
<span className="history-column-select" role="columnheader">
<input
aria-label="Alle sichtbaren Einträge auswählen"
checked={allVisibleSelected}
disabled={visibleIds.length === 0}
onChange={() => actions.onToggleSelectAll(visibleIds)}
type="checkbox"
/>
</span>
<span role="columnheader">Paket / Datei</span>
<span role="columnheader">Status</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Gestartet</span>
<span role="columnheader">Beendet</span>
<span role="columnheader">Aktion</span>
</div>
</DataTableHeader>
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
{model.loading ? (
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
) : model.error ? (
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
) : showEmpty ? (
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
) : (
model.rows.map((row) => {
const isSelected = selected.has(row.id);
const isExpanded = expanded.has(row.id);
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
actions.onContextMenu(row.id, event.clientX, event.clientY);
};
return (
<div className="history-row-group" key={row.id}>
<div
className={`history-row${isSelected ? " is-selected" : ""}`}
data-history-row-id={row.id}
onContextMenu={onContextMenu}
role="row"
>
<span className="history-column-select" role="cell">
<input
aria-label={`${row.name} auswählen`}
checked={isSelected}
onChange={() => actions.onToggleSelection(row.id)}
type="checkbox"
/>
</span>
<span className="history-row-name" role="cell">
<button
aria-expanded={isExpanded}
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
className="history-expand"
onClick={(event) => {
event.stopPropagation();
actions.onToggleExpansion(row.id);
}}
type="button"
>{isExpanded ? "" : "+"}</button>
<span title={row.name}>{row.name}</span>
</span>
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
<span className="history-row-time" role="cell">{row.startedLabel}</span>
<span className="history-row-time" role="cell">{row.completedLabel}</span>
<span className="history-row-action" role="cell">
<button
data-sliding-selection-active={model.filter === item.id}
data-sliding-selection-item="true"
key={item.id}
onClick={() => actions.onFilterChange(item.id)}
type="button"
>
<span>{item.label}</span>
<span>{model.counts[item.id]}</span>
</button>
))}
</SlidingSelection>
<button
className="history-sidebar-clear"
disabled={model.totalCount === 0 || model.loading}
onClick={actions.onClearHistory}
type="button"
>Verlauf leeren</button>
</div>
);
}
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
const selectedIds = model.selectedIds;
const selectedSet = new Set(selectedIds);
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
return (
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
<ToolbarGroup label="Einträge">
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
</ToolbarGroup>
<ToolbarSearch
label="Verlauf durchsuchen"
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
placeholder="Name, Pfad, Hoster oder Provider"
value={model.query}
/>
</Toolbar>
);
}
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
const selected = new Set(model.selectedIds);
const expanded = new Set(model.expandedIds);
const visibleIds = model.rows.map((row) => row.id);
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
? "Noch kein Verlauf"
: "Keine passenden Einträge";
return (
<section aria-label="Verlaufstabelle" className="history-content">
<DataTable className="history-table" label="Verlauf">
<DataTableHeader className="history-table-header">
<div className="history-table-header-row" role="row">
<span className="history-column-select" role="columnheader">
<input
aria-label="Alle sichtbaren Einträge auswählen"
checked={allVisibleSelected}
disabled={visibleIds.length === 0}
onChange={() => actions.onToggleSelectAll(visibleIds)}
type="checkbox"
/>
</span>
<span role="columnheader">Paket / Datei</span>
<span role="columnheader">Status</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Gestartet</span>
<span role="columnheader">Beendet</span>
<span role="columnheader">Aktion</span>
</div>
</DataTableHeader>
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
{model.loading ? (
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
) : model.error ? (
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
) : showEmpty ? (
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
) : (
model.rows.map((row) => {
const isSelected = selected.has(row.id);
const isExpanded = expanded.has(row.id);
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
actions.onContextMenu(row.id, event.clientX, event.clientY);
};
return (
<div className="history-row-group" key={row.id}>
<div
className={`history-row${isSelected ? " is-selected" : ""}`}
data-history-row-id={row.id}
onContextMenu={onContextMenu}
role="row"
>
<span className="history-column-select" role="cell">
<input
aria-label={`${row.name} auswählen`}
checked={isSelected}
onChange={() => actions.onToggleSelection(row.id)}
type="checkbox"
/>
</span>
<span className="history-row-name" role="cell">
<button
aria-expanded={isExpanded}
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
className="history-expand"
onClick={(event) => {
event.stopPropagation();
actions.onToggleExpansion(row.id);
}}
type="button"
>{isExpanded ? "" : "+"}</button>
<span title={row.name}>{row.name}</span>
</span>
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
<span className="history-row-time" role="cell">{row.startedLabel}</span>
<span className="history-row-time" role="cell">{row.completedLabel}</span>
<span className="history-row-action" role="cell">
<button
aria-label={`Aktionen für ${row.name}`}
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
actions.onContextMenu(row.id, rect.right, rect.bottom);
}}
type="button"
></button>
</span>
</div>
{isExpanded ? <HistoryRowDetails row={row} /> : null}
</div>
);
})
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
const count = model.rows.length;
return (
<DataTableFooter
className="history-pagination"
data-visual-region="history-pagination"
pageSize={count}
paginationVisible
rangeLabel={count === 0 ? "0 von 0" : `1${count} von ${count}`}
/>
);
}
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
return (
<div className="history-workspace-view">
<HistorySidebar actions={actions} model={model} />
<div className="history-view-main">
<HistoryToolbar actions={actions} model={model} />
<HistoryContent actions={actions} model={model} />
<HistoryFooter model={model} />
</div>
</div>
);
}
></button>
</span>
</div>
{isExpanded ? <HistoryRowDetails row={row} /> : null}
</div>
);
})
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
const count = model.rows.length;
return (
<DataTableFooter
className="history-pagination"
data-visual-region="history-pagination"
pageSize={count}
paginationVisible
rangeLabel={count === 0 ? "0 von 0" : `1${count} von ${count}`}
/>
);
}
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
return (
<div className="history-workspace-view">
<HistorySidebar actions={actions} model={model} />
<div className="history-view-main">
<HistoryToolbar actions={actions} model={model} />
<HistoryContent actions={actions} model={model} />
<HistoryFooter model={model} />
</div>
</div>
);
}
+363 -348
View File
@@ -1,366 +1,381 @@
.history-workspace-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.history-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
min-width: 0;
}
.history-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 100%;
padding: 12px;
}
.history-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 28px;
}
.history-filter-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.history-filter,
.history-sidebar-clear,
.history-action {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--ui-text-secondary);
display: flex;
font: inherit;
height: 36px;
}
.history-filter {
justify-content: space-between;
padding: 0 10px;
text-align: left;
width: 100%;
}
.history-filter span:last-child {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.history-filter:hover,
.history-sidebar-clear:hover:not(:disabled),
.history-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.history-workspace-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.history-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
min-width: 0;
}
.history-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 100%;
padding: 12px;
}
.history-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 28px;
}
.history-filter-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.history-filter,
.history-sidebar-clear,
.history-action {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--ui-text-secondary);
display: flex;
font: inherit;
height: 36px;
}
.history-filter {
justify-content: space-between;
padding: 0 10px;
text-align: left;
width: 100%;
}
.history-filter span:last-child {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.history-filter:hover,
.history-sidebar-clear:hover:not(:disabled),
.history-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.history-filter.is-active {
background: var(--ui-active);
border-color: var(--ui-border);
background: transparent;
border-color: transparent;
color: var(--ui-text);
}
.history-sidebar-clear {
border-color: var(--ui-border);
justify-content: flex-start;
padding: 0 10px;
width: 100%;
}
.history-sidebar-clear:disabled,
.history-action:disabled {
cursor: default;
opacity: 0.45;
}
.history-sidebar,
.history-workspace-toolbar,
.history-content,
.history-pagination {
user-select: none;
}
.history-workspace-toolbar {
min-width: 0;
width: 100%;
}
.history-workspace-toolbar .ui-toolbar-search-input {
user-select: text;
}
.history-action {
background: var(--ui-input);
border-color: var(--ui-border);
padding: 0 12px;
white-space: nowrap;
}
.history-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.history-content {
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.history-content .history-table {
height: 100%;
overflow: auto;
}
.history-table-header-row,
.history-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
min-width: 1080px;
}
.history-table > .history-table-header {
height: 41px;
position: sticky;
top: 0;
z-index: 1;
}
.history-sidebar-clear {
border-color: var(--ui-border);
justify-content: flex-start;
padding: 0 10px;
width: 100%;
}
.history-sidebar-clear:disabled,
.history-action:disabled {
cursor: default;
opacity: 0.45;
}
.history-sidebar,
.history-workspace-toolbar,
.history-content,
.history-pagination {
user-select: none;
}
.history-workspace-toolbar {
min-width: 0;
width: 100%;
}
.history-workspace-toolbar .ui-toolbar-search-input {
user-select: text;
}
.history-action {
background: var(--ui-input);
border-color: var(--ui-border);
padding: 0 12px;
white-space: nowrap;
}
.history-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.history-content {
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.history-content .history-table {
height: 100%;
overflow: auto;
}
.history-table-header-row,
.history-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
min-width: 1080px;
}
.history-table > .history-table-header {
height: 41px;
position: sticky;
top: 0;
z-index: 1;
}
.history-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.history-table-header-row > span,
.history-row > span {
min-width: 0;
padding: 0 10px;
.history-table-header-row > span {
text-align: center;
}
.history-table > .history-table-body {
overflow: visible;
.history-table-header-row > span:nth-child(2) {
text-align: left;
}
.history-row-group {
min-width: 1080px;
}
.history-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.history-row:hover {
background: var(--ui-hover);
}
.history-row.is-selected {
background: var(--ui-active);
}
.history-column-select {
display: grid;
place-items: center;
}
.history-row-name {
align-items: center;
color: var(--ui-text);
display: flex;
font-weight: 600;
gap: 8px;
overflow: hidden;
}
.history-row-name > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-expand,
.history-table-header-row > span,
.history-row > span {
min-width: 0;
padding: 0 10px;
}
.history-table > .history-table-body {
overflow: visible;
}
.history-row-group {
min-width: 1080px;
}
.history-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.history-row:hover {
background: var(--ui-hover);
}
.history-row.is-selected {
background: var(--ui-active);
}
.history-column-select {
display: grid;
place-items: center;
}
.history-row-name {
align-items: center;
color: var(--ui-text);
display: flex;
font-weight: 600;
gap: 8px;
overflow: hidden;
}
.history-row-name > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-expand,
.history-row-action button {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--ui-text-secondary);
flex: 0 0 auto;
font: inherit;
height: 28px;
}
.history-expand {
width: 28px;
}
.history-row-action button {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--ui-text-secondary);
flex: 0 0 auto;
font: inherit;
height: 28px;
}
.history-expand {
width: 28px;
}
.history-row-action button {
width: 36px;
}
.history-expand:hover,
.history-row-action button:hover {
background: var(--ui-hover);
border-color: var(--ui-border);
color: var(--ui-text);
}
.history-status {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 600;
line-height: 22px;
max-width: 100%;
overflow: hidden;
padding: 0 8px;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 5px;
color: var(--ui-text);
height: 30px;
min-height: 30px;
padding: 0;
width: 30px;
}
.history-status-completed {
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
color: var(--ui-success);
}
.history-expand:hover,
.history-row-action button:hover {
background: var(--ui-hover);
border-color: var(--ui-border);
color: var(--ui-text);
}
.history-status {
border: 1px solid var(--ui-border);
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 600;
line-height: 22px;
max-width: 100%;
overflow: hidden;
padding: 0 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-status-completed {
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
color: var(--ui-success);
}
.history-status-deleted {
background: color-mix(in srgb, var(--ui-warning) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
color: var(--ui-warning);
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger);
}
.history-row-size,
.history-row-hoster,
.history-row-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-row-size,
.history-row-time {
font-variant-numeric: tabular-nums;
}
.history-row-action {
display: grid;
place-items: center;
}
.history-detail-row {
border-bottom: 1px solid var(--ui-border);
background: var(--ui-input);
min-width: 1080px;
}
.history-detail-cell {
padding: 14px 48px;
}
.history-details-grid {
display: grid;
gap: 12px 24px;
grid-template-columns: repeat(4, minmax(120px, 1fr));
margin: 0;
}
.history-details-grid > div {
display: grid;
gap: 4px;
min-width: 0;
}
.history-details-grid dt {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.history-details-grid dd {
color: var(--ui-text-secondary);
margin: 0;
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-copyable {
overflow-wrap: anywhere;
user-select: text;
white-space: pre-wrap;
}
.history-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.history-pagination {
height: 60px;
}
@media (max-width: 1366px) {
.history-workspace-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.history-sidebar {
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
min-width: 996px;
}
.history-row-group,
.history-detail-row {
min-width: 996px;
}
}
@media (max-width: 1120px) {
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
min-width: 932px;
}
.history-row-group,
.history-detail-row {
min-width: 932px;
}
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger);
}
.history-row-size,
.history-row-hoster,
.history-row-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-row-size,
.history-row-time {
font-variant-numeric: tabular-nums;
}
.history-row-action {
display: grid;
place-items: center;
}
.history-detail-row {
border-bottom: 1px solid var(--ui-border);
background: var(--ui-input);
min-width: 1080px;
}
.history-detail-cell {
padding: 14px 48px;
}
.history-details-grid {
display: grid;
gap: 12px 24px;
grid-template-columns: repeat(4, minmax(120px, 1fr));
margin: 0;
}
.history-details-grid > div {
display: grid;
gap: 4px;
min-width: 0;
}
.history-details-grid dt {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.history-details-grid dd {
color: var(--ui-text-secondary);
margin: 0;
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-copyable {
overflow-wrap: anywhere;
user-select: text;
white-space: pre-wrap;
}
.history-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.history-pagination {
height: 60px;
}
@media (max-width: 1366px) {
.history-workspace-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.history-sidebar {
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
min-width: 996px;
}
.history-row-group,
.history-detail-row {
min-width: 996px;
}
}
@media (max-width: 1120px) {
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
min-width: 932px;
}
.history-row-group,
.history-detail-row {
min-width: 932px;
}
}
File diff suppressed because it is too large Load Diff
+97 -94
View File
@@ -1,99 +1,102 @@
import type { ReactElement } from "react";
import {
SETTINGS_SECTIONS,
getSettingsSaveLabel,
type SettingsFormViewModel,
type SettingsSaveState,
type SettingsSection
} from "./settings-model";
import {
AccountWorkspace,
type AccountWorkspaceActions,
type AccountWorkspaceViewModel
} from "./AccountWorkspace";
import type { ReactElement } from "react";
import {
SETTINGS_SECTIONS,
getSettingsSaveLabel,
type SettingsFormViewModel,
type SettingsSaveState,
type SettingsSection
} from "./settings-model";
import {
AccountWorkspace,
type AccountWorkspaceActions,
type AccountWorkspaceViewModel
} from "./AccountWorkspace";
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
import "./settings.css";
export type SettingsViewRegion = "all" | "sidebar" | "content";
export interface SettingsViewModel {
section: SettingsSection;
saveState: SettingsSaveState;
form: SettingsFormViewModel;
accounts: AccountWorkspaceViewModel;
}
export interface SettingsViewActions {
onSectionChange: (section: SettingsSection) => void;
onSave: () => void;
form: SettingsFormActions;
accounts: AccountWorkspaceActions;
}
export interface SettingsViewProps {
model: SettingsViewModel;
actions: SettingsViewActions;
region?: SettingsViewRegion;
}
import { SlidingSelection } from "../../ui/SlidingSelection";
import "./settings.css";
export type SettingsViewRegion = "all" | "sidebar" | "content";
export interface SettingsViewModel {
section: SettingsSection;
saveState: SettingsSaveState;
form: SettingsFormViewModel;
accounts: AccountWorkspaceViewModel;
}
export interface SettingsViewActions {
onSectionChange: (section: SettingsSection) => void;
onSave: () => void;
form: SettingsFormActions;
accounts: AccountWorkspaceActions;
}
export interface SettingsViewProps {
model: SettingsViewModel;
actions: SettingsViewActions;
region?: SettingsViewRegion;
}
export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactElement {
return (
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
<div className="settings-sidebar-heading">
<strong>Einstellungen</strong>
</div>
<div className="settings-sidebar-list">
{SETTINGS_SECTIONS.map((section) => (
<button
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
<div className="settings-sidebar-heading">
<strong>Einstellungen</strong>
</div>
<SlidingSelection activeKey={model.section} axis="vertical" className="settings-sidebar-list">
{SETTINGS_SECTIONS.map((section) => (
<button
aria-current={model.section === section.id ? "page" : undefined}
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
key={section.id}
onClick={() => actions.onSectionChange(section.id)}
type="button"
>{section.label}</button>
))}
</div>
</nav>
);
}
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
const saveLabel = getSettingsSaveLabel(model.saveState);
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
return (
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
<header className="settings-content-header">
<div>
<h1>Einstellungen</h1>
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
</div>
<button
className="settings-button settings-button-primary"
disabled={saveDisabled}
onClick={actions.onSave}
type="button"
>Einstellungen speichern</button>
</header>
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
{model.section === "accounts"
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
: <SettingsForm actions={actions.form} model={model.form} />}
</div>
</section>
);
}
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
if (region === "sidebar") {
return <SettingsSidebar actions={actions} model={model} />;
}
if (region === "content") {
return <SettingsContent actions={actions} model={model} />;
}
return (
<div className="settings-view">
<SettingsSidebar actions={actions} model={model} />
<SettingsContent actions={actions} model={model} />
</div>
);
}
data-sliding-selection-active={model.section === section.id}
data-sliding-selection-item="true"
key={section.id}
onClick={() => actions.onSectionChange(section.id)}
type="button"
>{section.label}</button>
))}
</SlidingSelection>
</nav>
);
}
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
const saveLabel = getSettingsSaveLabel(model.saveState);
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
return (
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
<header className="settings-content-header">
<div>
<h1>Einstellungen</h1>
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
</div>
<button
className="settings-button settings-button-primary"
disabled={saveDisabled}
onClick={actions.onSave}
type="button"
>Einstellungen speichern</button>
</header>
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
{model.section === "accounts"
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
: <SettingsForm actions={actions.form} model={model.form} />}
</div>
</section>
);
}
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
if (region === "sidebar") {
return <SettingsSidebar actions={actions} model={model} />;
}
if (region === "content") {
return <SettingsContent actions={actions} model={model} />;
}
return (
<div className="settings-view">
<SettingsSidebar actions={actions} model={model} />
<SettingsContent actions={actions} model={model} />
</div>
);
}
@@ -408,6 +408,22 @@ export function buildSettingsFormViewModel({
title: "Allgemein",
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
groups: [
{
id: "general-language",
title: "Sprache",
fields: [
{
id: "language",
kind: "select",
label: "Sprache",
value: settings.language,
options: [
{ value: "en", label: "English" },
{ value: "de", label: "Deutsch" }
]
}
]
},
{
id: "general-storage",
title: "Speicherort",
+9 -5
View File
@@ -53,8 +53,8 @@
}
.settings-sidebar-item.is-active {
border-color: var(--ui-border);
background: var(--ui-active);
border-color: transparent;
background: transparent;
color: var(--ui-text);
}
@@ -439,7 +439,7 @@
}
.settings-account-tabs button[aria-selected="true"] {
border-bottom-color: var(--ui-accent);
border-bottom-color: transparent;
color: var(--ui-text);
}
@@ -474,13 +474,17 @@
.settings-account-table-grid {
height: 41px;
color: var(--ui-text-muted);
color: var(--ui-text);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.settings-form-group:has(#update\:check) .settings-action-field {
justify-content: flex-end;
}
.settings-account-table-grid > span,
.settings-account-row > span {
min-width: 0;
@@ -604,7 +608,7 @@
.settings-account-traffic,
.settings-account-expires,
.settings-account-credential {
color: var(--ui-text-muted);
color: var(--ui-text);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
+223 -220
View File
@@ -1,226 +1,229 @@
import type { ReactElement, ReactNode } from "react";
import type { ReactElement, ReactNode } from "react";
import type {
StatisticsMetric,
StatisticsProviderScope,
StatisticsRange,
StatisticsViewModel
StatisticsMetric,
StatisticsProviderScope,
StatisticsRange,
StatisticsViewModel
} from "./statistics-model";
import "./statistics.css";
export interface StatisticsViewActions {
onRangeChange: (range: StatisticsRange) => void;
onResetSession: () => void;
onResetAll: () => void;
onResetErrors: () => void;
}
export interface StatisticsViewProps {
model: StatisticsViewModel;
actions: StatisticsViewActions;
chart: ReactNode;
}
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
{ id: "session", label: "Sitzung" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Sieben Tage" },
{ id: "month", label: "30 Tage" },
{ id: "all", label: "Gesamt" }
];
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
function formatBytes(bytes: number): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
if (!metric.available || metric.value === null) {
return "";
}
if (kind === "bytes") {
return formatBytes(metric.value);
}
if (kind === "speed") {
return `${formatBytes(metric.value)}/s`;
}
if (kind === "percent") {
return `${numberFormatter.format(metric.value)} %`;
}
return numberFormatter.format(metric.value);
}
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
if (scope === "current-queue") {
return "Aktuelle Queue";
}
if (scope === "today") {
return "Heute";
}
if (scope === "all") {
return "Gesamt";
}
return "Nicht verfügbar";
}
function emptyProviderMessage(model: StatisticsViewModel): string {
if (model.coverage === "unavailable") {
return model.message;
}
if (model.providerScope === "today") {
return "Heute wurden noch keine Providerbytes erfasst.";
}
if (model.providerScope === "all") {
return "Noch keine gespeicherten Providerbytes vorhanden.";
}
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
}
function StatisticsMetricCard({
label,
metric,
kind
}: {
label: string;
metric: StatisticsMetric;
kind: "bytes" | "count" | "percent" | "speed";
}): ReactElement {
return (
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
<span className="statistics-kpi-label">{label}</span>
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
</article>
);
}
import { SlidingSelection } from "../../ui/SlidingSelection";
import "./statistics.css";
export interface StatisticsViewActions {
onRangeChange: (range: StatisticsRange) => void;
onResetSession: () => void;
onResetAll: () => void;
onResetErrors: () => void;
}
export interface StatisticsViewProps {
model: StatisticsViewModel;
actions: StatisticsViewActions;
chart: ReactNode;
}
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
{ id: "session", label: "Sitzung" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Sieben Tage" },
{ id: "month", label: "30 Tage" },
{ id: "all", label: "Gesamt" }
];
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
function formatBytes(bytes: number): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
if (!metric.available || metric.value === null) {
return "";
}
if (kind === "bytes") {
return formatBytes(metric.value);
}
if (kind === "speed") {
return `${formatBytes(metric.value)}/s`;
}
if (kind === "percent") {
return `${numberFormatter.format(metric.value)} %`;
}
return numberFormatter.format(metric.value);
}
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
if (scope === "current-queue") {
return "Aktuelle Queue";
}
if (scope === "today") {
return "Heute";
}
if (scope === "all") {
return "Gesamt";
}
return "Nicht verfügbar";
}
function emptyProviderMessage(model: StatisticsViewModel): string {
if (model.coverage === "unavailable") {
return model.message;
}
if (model.providerScope === "today") {
return "Heute wurden noch keine Providerbytes erfasst.";
}
if (model.providerScope === "all") {
return "Noch keine gespeicherten Providerbytes vorhanden.";
}
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
}
function StatisticsMetricCard({
label,
metric,
kind
}: {
label: string;
metric: StatisticsMetric;
kind: "bytes" | "count" | "percent" | "speed";
}): ReactElement {
return (
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
<span className="statistics-kpi-label">{label}</span>
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
</article>
);
}
export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps, "model" | "actions">): ReactElement {
return (
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
<strong className="statistics-sidebar-heading">Zeitraum</strong>
<div className="statistics-range-list">
{rangeItems.map((item) => (
<button
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
<strong className="statistics-sidebar-heading">Zeitraum</strong>
<SlidingSelection activeKey={model.range} axis="vertical" className="statistics-range-list">
{rangeItems.map((item) => (
<button
aria-current={model.range === item.id ? "page" : undefined}
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
key={item.id}
onClick={() => actions.onRangeChange(item.id)}
type="button"
>{item.label}</button>
))}
</div>
<p className="statistics-sidebar-message">{model.message}</p>
</nav>
);
}
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
const metrics = model.metrics;
const rows = [
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
model.providerScope ? `Provider: ${model.providers.length}` : null
].filter((value): value is string => value !== null);
if (rows.length === 0) {
return null;
}
return (
<div className="statistics-sidebar-status">
{rows.map((row) => <span key={row}>{row}</span>)}
</div>
);
}
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<section aria-label="Statistik-Dashboard" className="statistics-content">
<header className="statistics-heading">
<div>
<h2>Statistiken</h2>
<p>{model.message}</p>
</div>
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
<button
className="statistics-reset statistics-reset-danger"
disabled={!model.errorResetAvailable}
onClick={actions.onResetErrors}
type="button"
>Fehler zurücksetzen</button>
</div>
</header>
<div className="statistics-kpis" data-visual-region="statistics-kpis">
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
</div>
<div className="statistics-detail-grid">
<section className="statistics-chart" data-visual-region="statistics-chart">
<div className="statistics-section-heading">
<h3>Bandbreitenverlauf</h3>
<span>Live aus der aktuellen Renderer-Sitzung</span>
</div>
<div className="statistics-chart-canvas">{chart}</div>
</section>
<section className="statistics-providers">
<div className="statistics-section-heading">
<h3>Provider</h3>
<span>{providerScopeLabel(model.providerScope)}</span>
</div>
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
<div className="statistics-provider-header" role="row">
<span role="columnheader">Provider</span>
<span role="columnheader">Daten</span>
<span role="columnheader">Ergebnisse</span>
</div>
<div className="statistics-provider-body" role="rowgroup">
{model.providers.length > 0 ? model.providers.map((provider) => (
<div className="statistics-provider-row" key={provider.id} role="row">
<span className="statistics-provider-name" role="cell">{provider.label}</span>
<span role="cell">{formatBytes(provider.bytes)}</span>
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
{provider.completed === null || provider.failed === null
? ""
: `${provider.completed} fertig · ${provider.failed} Fehler`}
</span>
</div>
)) : (
<div className="statistics-provider-empty" role="row">
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
</div>
)}
</div>
</div>
</section>
</div>
</section>
);
}
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<div className="statistics-composed-view">
<StatisticsSidebar actions={actions} model={model} />
<StatisticsContent actions={actions} chart={chart} model={model} />
</div>
);
}
data-sliding-selection-active={model.range === item.id}
data-sliding-selection-item="true"
key={item.id}
onClick={() => actions.onRangeChange(item.id)}
type="button"
>{item.label}</button>
))}
</SlidingSelection>
<p className="statistics-sidebar-message">{model.message}</p>
</nav>
);
}
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
const metrics = model.metrics;
const rows = [
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
model.providerScope ? `Provider: ${model.providers.length}` : null
].filter((value): value is string => value !== null);
if (rows.length === 0) {
return null;
}
return (
<div className="statistics-sidebar-status">
{rows.map((row) => <span key={row}>{row}</span>)}
</div>
);
}
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<section aria-label="Statistik-Dashboard" className="statistics-content">
<header className="statistics-heading">
<div>
<h2>Statistiken</h2>
<p>{model.message}</p>
</div>
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
<button
className="statistics-reset statistics-reset-danger"
disabled={!model.errorResetAvailable}
onClick={actions.onResetErrors}
type="button"
>Fehler zurücksetzen</button>
</div>
</header>
<div className="statistics-kpis" data-visual-region="statistics-kpis">
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
</div>
<div className="statistics-detail-grid">
<section className="statistics-chart" data-visual-region="statistics-chart">
<div className="statistics-section-heading">
<h3>Bandbreitenverlauf</h3>
<span>Live aus der aktuellen Renderer-Sitzung</span>
</div>
<div className="statistics-chart-canvas">{chart}</div>
</section>
<section className="statistics-providers">
<div className="statistics-section-heading">
<h3>Provider</h3>
<span>{providerScopeLabel(model.providerScope)}</span>
</div>
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
<div className="statistics-provider-header" role="row">
<span role="columnheader">Provider</span>
<span role="columnheader">Daten</span>
<span role="columnheader">Ergebnisse</span>
</div>
<div className="statistics-provider-body" role="rowgroup">
{model.providers.length > 0 ? model.providers.map((provider) => (
<div className="statistics-provider-row" key={provider.id} role="row">
<span className="statistics-provider-name" role="cell">{provider.label}</span>
<span role="cell">{formatBytes(provider.bytes)}</span>
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
{provider.completed === null || provider.failed === null
? ""
: `${provider.completed} fertig · ${provider.failed} Fehler`}
</span>
</div>
)) : (
<div className="statistics-provider-empty" role="row">
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
</div>
)}
</div>
</div>
</section>
</div>
</section>
);
}
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<div className="statistics-composed-view">
<StatisticsSidebar actions={actions} model={model} />
<StatisticsContent actions={actions} chart={chart} model={model} />
</div>
);
}
+367 -367
View File
@@ -1,370 +1,370 @@
.statistics-sidebar,
.statistics-content,
.statistics-sidebar-status {
user-select: none;
}
.statistics-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.statistics-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
}
.statistics-range-list {
display: grid;
gap: 4px;
}
.statistics-range {
display: flex;
width: 100%;
min-height: 36px;
padding: 8px 10px;
align-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
text-align: left;
}
.statistics-range:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-sidebar,
.statistics-content,
.statistics-sidebar-status {
user-select: none;
}
.statistics-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.statistics-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
}
.statistics-range-list {
display: grid;
gap: 4px;
}
.statistics-range {
display: flex;
width: 100%;
min-height: 36px;
padding: 8px 10px;
align-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
text-align: left;
}
.statistics-range:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-range-active {
border-color: var(--ui-border);
background: var(--ui-active);
border-color: transparent;
background: transparent;
color: var(--ui-text);
}
.statistics-range:focus-visible,
.statistics-reset:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.statistics-sidebar-message {
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-sidebar-status {
display: contents;
}
.statistics-content {
display: grid;
min-width: 0;
min-height: 100%;
align-content: start;
gap: 16px;
padding: 20px 24px 24px;
overflow: auto;
}
.statistics-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.statistics-heading h2,
.statistics-section-heading h3 {
margin: 0;
color: var(--ui-text);
}
.statistics-heading h2 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.statistics-heading p {
max-width: 760px;
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-reset-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.statistics-reset {
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
}
.statistics-reset:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-reset-danger {
color: var(--ui-danger);
}
.statistics-reset:disabled {
color: var(--ui-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
.statistics-kpis {
display: grid;
min-width: 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.statistics-kpi {
display: flex;
min-width: 0;
min-height: 116px;
padding: 14px 16px;
flex-direction: column;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-kpi-label {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-kpi-value {
min-width: 0;
color: var(--ui-text);
font-size: clamp(20px, 1.6vw, 28px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.statistics-kpi-danger .statistics-kpi-value {
color: var(--ui-danger);
}
.statistics-kpi-source {
min-height: 32px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
overflow-wrap: anywhere;
}
.statistics-detail-grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 16px;
}
.statistics-chart,
.statistics-providers {
display: flex;
min-width: 0;
min-height: 340px;
padding: 16px;
flex-direction: column;
gap: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-section-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.statistics-section-heading h3 {
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.statistics-section-heading span {
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
text-align: right;
}
.statistics-chart-canvas {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
}
.statistics-chart-canvas .bandwidth-chart-container {
height: 100%;
min-height: 280px;
margin: 0;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
}
.statistics-provider-table {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 6px;
}
.statistics-provider-header,
.statistics-provider-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
align-items: center;
gap: 12px;
}
.statistics-provider-header {
min-height: 41px;
padding: 0 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-provider-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
.statistics-provider-row {
min-height: 48px;
padding: 8px 12px;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 18px;
}
.statistics-provider-row:last-child {
border-bottom: 0;
}
.statistics-provider-row:hover {
background: var(--ui-hover);
}
.statistics-provider-row > span {
min-width: 0;
overflow-wrap: anywhere;
}
.statistics-provider-name {
color: var(--ui-text);
font-weight: 600;
}
.statistics-provider-errors {
color: var(--ui-danger);
}
.statistics-provider-empty {
display: grid;
min-height: 180px;
padding: 24px;
place-items: center;
color: var(--ui-text-muted);
font-size: 13px;
line-height: 20px;
text-align: center;
}
.statistics-composed-view {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: 270px minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
@media (max-width: 1500px) {
.statistics-composed-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.statistics-sidebar {
overflow: hidden;
}
.statistics-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.statistics-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 1120px) {
.statistics-content {
padding: 16px;
}
.statistics-heading {
flex-direction: column;
}
.statistics-reset-actions {
justify-content: flex-start;
}
.statistics-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.statistics-range:focus-visible,
.statistics-reset:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.statistics-sidebar-message {
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-sidebar-status {
display: contents;
}
.statistics-content {
display: grid;
min-width: 0;
min-height: 100%;
align-content: start;
gap: 16px;
padding: 20px 24px 24px;
overflow: auto;
}
.statistics-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.statistics-heading h2,
.statistics-section-heading h3 {
margin: 0;
color: var(--ui-text);
}
.statistics-heading h2 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.statistics-heading p {
max-width: 760px;
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-reset-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.statistics-reset {
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
}
.statistics-reset:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-reset-danger {
color: var(--ui-danger);
}
.statistics-reset:disabled {
color: var(--ui-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
.statistics-kpis {
display: grid;
min-width: 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.statistics-kpi {
display: flex;
min-width: 0;
min-height: 116px;
padding: 14px 16px;
flex-direction: column;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-kpi-label {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-kpi-value {
min-width: 0;
color: var(--ui-text);
font-size: clamp(20px, 1.6vw, 28px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.statistics-kpi-danger .statistics-kpi-value {
color: var(--ui-danger);
}
.statistics-kpi-source {
min-height: 32px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
overflow-wrap: anywhere;
}
.statistics-detail-grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 16px;
}
.statistics-chart,
.statistics-providers {
display: flex;
min-width: 0;
min-height: 340px;
padding: 16px;
flex-direction: column;
gap: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-section-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.statistics-section-heading h3 {
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.statistics-section-heading span {
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
text-align: right;
}
.statistics-chart-canvas {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
}
.statistics-chart-canvas .bandwidth-chart-container {
height: 100%;
min-height: 280px;
margin: 0;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
}
.statistics-provider-table {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 6px;
}
.statistics-provider-header,
.statistics-provider-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
align-items: center;
gap: 12px;
}
.statistics-provider-header {
min-height: 41px;
padding: 0 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-provider-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
.statistics-provider-row {
min-height: 48px;
padding: 8px 12px;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 18px;
}
.statistics-provider-row:last-child {
border-bottom: 0;
}
.statistics-provider-row:hover {
background: var(--ui-hover);
}
.statistics-provider-row > span {
min-width: 0;
overflow-wrap: anywhere;
}
.statistics-provider-name {
color: var(--ui-text);
font-weight: 600;
}
.statistics-provider-errors {
color: var(--ui-danger);
}
.statistics-provider-empty {
display: grid;
min-height: 180px;
padding: 24px;
place-items: center;
color: var(--ui-text-muted);
font-size: 13px;
line-height: 20px;
text-align: center;
}
.statistics-composed-view {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: 270px minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
@media (max-width: 1500px) {
.statistics-composed-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.statistics-sidebar {
overflow: hidden;
}
.statistics-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.statistics-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 1120px) {
.statistics-content {
padding: 16px;
}
.statistics-heading {
flex-direction: column;
}
.statistics-reset-actions {
justify-content: flex-start;
}
.statistics-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+9 -6
View File
@@ -26,7 +26,8 @@ export type DebridProvider =
| "debridlink"
| "linksnappy";
export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light";
export type AppTheme = "dark" | "light";
export type AppLanguage = "en" | "de";
export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent";
@@ -66,8 +67,9 @@ export interface DebridAccountStatus {
checkedAt: number;
}
export interface AppSettings {
token: string;
export interface AppSettings {
language: AppLanguage;
token: string;
realDebridUseWebLogin: boolean;
megaLogin: string;
megaPassword: string;
@@ -143,9 +145,10 @@ export interface AppSettings {
totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number;
bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
extractCpuPriority: ExtractCpuPriority;
bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[];
columnOrderVersion?: number;
extractCpuPriority: ExtractCpuPriority;
autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>;