diff --git a/.gitignore b/.gitignore index 35e868a..65919ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ node_modules/ +.worktrees/ +.claude/ +.codex/ +.superpowers/ +docs/superpowers/ +tasks/ +AGENTS.md +CLAUDE.md build/ dist/ release/ diff --git a/package-lock.json b/package-lock.json index 68b3598..c007703 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.12", + "version": "2.0.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.12", + "version": "2.0.13", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index 3341c5c..5fcde58 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.12", + "version": "2.0.13", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", @@ -8,6 +8,7 @@ "scripts": { "dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:renderer": "vite", + "visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort", "dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch", "dev:electron": "wait-on tcp:5173 file:build/main/main/main.js && cross-env NODE_ENV=development electron .", "build": "npm run build:main && npm run build:renderer", diff --git a/src/main/history-reveal.ts b/src/main/history-reveal.ts new file mode 100644 index 0000000..6b5b962 --- /dev/null +++ b/src/main/history-reveal.ts @@ -0,0 +1,104 @@ +import { win32 } from "node:path"; +import type { HistoryEntry, HistoryRevealResult } from "../shared/types"; + +export interface HistoryRevealRequest { + entryId: unknown; +} + +export interface HistoryRevealStat { + isDirectory: () => boolean; +} + +export interface HistoryRevealDependencies { + loadHistory: () => HistoryEntry[] | Promise; + stat: (directory: string) => Promise; + openPath: (directory: string) => Promise; +} + +function normalizeHistoryDirectory(value: unknown): string | null { + if (typeof value !== "string" || value.length === 0 || /[\u0000-\u001f\u007f]/.test(value)) { + return null; + } + const candidate = value.replaceAll("/", "\\"); + const lower = candidate.toLocaleLowerCase("en-US"); + if ( + lower.startsWith("\\\\.\\") + || lower.startsWith("\\\\?\\") + || lower.startsWith("\\??\\") + || lower.startsWith("\\\\globalroot\\") + || lower.startsWith("\\\\device\\") + ) { + return null; + } + const driveAbsolute = /^[A-Za-z]:\\/.test(candidate); + const uncMatch = /^\\\\([^\\]+)\\([^\\]+)(?:\\.*)?$/.exec(candidate); + if (!driveAbsolute && !uncMatch) { + return null; + } + if (driveAbsolute) { + if (candidate.slice(2).includes(":")) { + return null; + } + } else if (candidate.includes(":")) { + return null; + } + if (/[<>|"?*]/.test(driveAbsolute ? candidate.slice(3) : candidate.slice(2))) { + return null; + } + if (uncMatch) { + const server = uncMatch[1].toLocaleLowerCase("en-US"); + const share = uncMatch[2].toLocaleLowerCase("en-US"); + if ([".", "..", "?", "globalroot", "device"].includes(server) || share === "." || share === "..") { + return null; + } + } + const normalized = win32.normalize(candidate); + if (/^[A-Za-z]:\\/.test(normalized)) { + return normalized; + } + if (/^\\\\[^\\]+\\[^\\]+(?:\\.*)?$/.test(normalized)) { + return normalized; + } + return null; +} + +export async function revealHistoryEntry( + request: HistoryRevealRequest, + dependencies: HistoryRevealDependencies +): Promise { + if ( + typeof request.entryId !== "string" + || request.entryId.length === 0 + || request.entryId.length > 256 + || request.entryId.trim() !== request.entryId + ) { + return { ok: false, reason: "entry-not-found" }; + } + const entries = await dependencies.loadHistory(); + const entry = entries.find((candidate) => candidate.id === request.entryId); + if (!entry) { + return { ok: false, reason: "entry-not-found" }; + } + const normalizedDirectory = normalizeHistoryDirectory(entry.outputDir); + if (!normalizedDirectory) { + return { ok: false, reason: "invalid-output-dir" }; + } + let stat: HistoryRevealStat; + try { + stat = await dependencies.stat(normalizedDirectory); + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + return { ok: false, reason: code === "ENOENT" || code === "ENOTDIR" ? "output-dir-missing" : "open-failed" }; + } + if (!stat.isDirectory()) { + return { ok: false, reason: "output-dir-not-directory" }; + } + try { + const error = await dependencies.openPath(normalizedDirectory); + return error === "" ? { ok: true } : { ok: false, reason: "open-failed" }; + } catch { + return { ok: false, reason: "open-failed" }; + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 82d1c84..20e1868 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -9,7 +9,8 @@ import { getRecentErrors } from "./error-ring"; import { sendNotification } from "./notify"; import { APP_NAME } from "./constants"; import { extractHttpLinksFromText } from "./utils"; -import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; +import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; +import { revealHistoryEntry } from "./history-reveal"; function validateString(value: unknown, name: string): string { if (typeof value !== "string") { @@ -506,10 +507,17 @@ function registerIpcHandlers(): void { }); ipcMain.handle(IPC_CHANNELS.GET_HISTORY, () => controller.getHistory()); ipcMain.handle(IPC_CHANNELS.CLEAR_HISTORY, () => controller.clearHistory()); - ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { - validateString(entryId, "entryId"); - return controller.removeHistoryEntry(entryId); - }); + ipcMain.handle(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: string) => { + validateString(entryId, "entryId"); + return controller.removeHistoryEntry(entryId); + }); + ipcMain.handle(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, (_event: IpcMainInvokeEvent, entryId: unknown) => { + return revealHistoryEntry({ entryId }, { + loadHistory: () => controller.getHistory(), + stat: (directory) => fs.promises.stat(directory), + openPath: (directory) => shell.openPath(directory) + }); + }); ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => { const options = { defaultPath: `rd-queue-export.json`, diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 9c3dc16..3159151 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -8,7 +8,8 @@ import { DebridProvider, DuplicatePolicy, EnableRemoteDiagnosticsInput, - HistoryEntry, + HistoryEntry, + HistoryRevealResult, PackagePriority, RemoteDiagnosticsInfo, RendererErrorReport, @@ -92,9 +93,10 @@ const api: ElectronApi = { retryExtraction: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), extractNow: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), resetPackage: (packageId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), - getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), - clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), - removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), + getHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), + clearHistory: (): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), + removeHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), + revealHistoryEntry: (entryId: string): Promise => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId), setPackagePriority: (packageId: string, priority: PackagePriority): Promise => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), skipItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index db7b750..6e3ef47 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,11 +1,10 @@ -import { DragEvent, KeyboardEvent as ReactKeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { DragEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { getMegaDebridAccountId, parseMegaDebridAccounts, serializeMegaDebridAccounts, maskMegaDebridLogin } from "../shared/mega-debrid-accounts"; import type { AllDebridHostInfo, AppSettings, AppTheme, - AudioStripSummary, BandwidthScheduleEntry, DebugSetupCheckResult, DebridFallbackProvider, @@ -38,22 +37,154 @@ import { } from "../shared/provider-daily-limits"; import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; import { pruneSelection } from "./selection"; -import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, isAccountRowSelectionKey, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; +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 type { DownloadSpeedHistoryState } from "./download-speed-state"; +import { extractHoster, formatDateTime, formatSpeedMbps, humanSize, providerLabels } from "./download-format"; +import { AppShell } from "./shell/AppShell"; +import { AvatarMenu } from "./shell/AvatarMenu"; +import { OverlayHost } from "./shell/OverlayHost"; +import { UpdateExperience } from "./shell/UpdateExperience"; +import type { MainView } from "./shell/shell-model"; +import { ContextMenu } from "./ui/ContextMenu"; +import { Dialog } from "./ui/Dialog"; +import { Icon } from "./ui/Icon"; +import { Toast } from "./ui/Toast"; +import { + buildCollectorViewModel, + type CollectorSourceTab +} from "./views/collector/collector-model"; +import { + CollectorContent, + CollectorInputDialog, + CollectorSidebar, + CollectorToolbar, + type CollectorViewActions +} from "./views/collector/CollectorView"; +import { + buildHistoryViewModel, + pruneHistoryIds, + selectVisibleHistoryIds, + type HistoryFilter +} from "./views/history/history-model"; +import { + HistoryContent, + HistoryFooter, + HistorySidebar, + HistoryToolbar, + type HistoryViewActions +} from "./views/history/HistoryView"; +import { + buildStatisticsViewModel, + type StatisticsRange +} from "./views/statistics/statistics-model"; +import { + StatisticsContent, + StatisticsSidebar, + StatisticsSidebarStatus, + type StatisticsViewActions +} from "./views/statistics/StatisticsView"; +import { buildDownloadsViewModel, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; +import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; +import { + DownloadsContent, + DownloadsFooter, + DownloadsSidebar, + DownloadsSidebarStatus, + DownloadsToolbar, + type DownloadsViewActions, + type DownloadsViewModel +} from "./views/downloads/DownloadsView"; +import { + buildAccountRowId, + buildSettingsFormViewModel, + buildTargetedAccountCheck, + projectAccountRows, + sortAccountRows, + type AccountAddOption, + type AccountRowSource, + type SettingsFormViewModel, + type SettingsSaveState, + type SettingsSection +} from "./views/settings/settings-model"; +import { + AccountAddDialog, + AccountEditDialog, + type AccountDialogField, + type AccountWorkspaceActions, + type AccountWorkspaceViewModel +} from "./views/settings/AccountWorkspace"; +import { + SettingsContent, + SettingsSidebar, + type SettingsViewActions, + type SettingsViewModel +} from "./views/settings/SettingsView"; + +type Tab = MainView; -type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; -type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; - -interface CollectorTab { - id: string; - name: string; - text: string; -} +type CollectorTab = CollectorSourceTab; + +interface CollectorInputState { + tabId: string; + tabName: string; + baseText: string; + draft: string; +} +export function mergeCollectorDraftText(baseText: string, currentText: string, draft: string): string { + if (currentText === baseText) { + return draft; + } + const appended = currentText.startsWith(baseText) ? currentText.slice(baseText.length) : currentText; + if (!appended) { + return draft; + } + const normalizedAppend = appended.replace(/^\r?\n/, ""); + if (!draft) { + return normalizedAppend; + } + if (!normalizedAppend) { + return draft; + } + return `${draft}${draft.endsWith("\n") ? "" : "\n"}${normalizedAppend}`; +} + +export function planCollectorTabRemoval( + tabs: CollectorTab[], + activeTabId: string, + removedTabId: string +): { tabs: CollectorTab[]; activeTabId: string } { + if (tabs.length <= 1) { + return { tabs, activeTabId }; + } + const removedIndex = tabs.findIndex((tab) => tab.id === removedTabId); + if (removedIndex < 0) { + return { + tabs, + activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : (tabs[0]?.id ?? "") + }; + } + const nextTabs = tabs.filter((tab) => tab.id !== removedTabId); + const nextActiveTabId = activeTabId === removedTabId + ? (nextTabs[Math.max(0, removedIndex - 1)]?.id ?? nextTabs[0]?.id ?? "") + : (nextTabs.some((tab) => tab.id === activeTabId) ? activeTabId : (nextTabs[0]?.id ?? "")); + return { tabs: nextTabs, activeTabId: nextActiveTabId }; +} + +export function planCollectorTextReplacement( + tabs: CollectorTab[], + tabId: string, + text: string +): { tabs: CollectorTab[]; selectedIds: string[] } { + return { + tabs: tabs.map((tab) => tab.id === tabId ? { ...tab, text } : tab), + selectedIds: [] + }; +} interface StartConflictPromptState { entry: StartConflictEntry; @@ -177,11 +308,32 @@ interface AccountTableRow { interface AccountContextMenuState { x: number; y: number; - row: AccountTableRow; + rowId: string; } -function AccountServiceLogo({ service, className }: { service: AccountService; className: string }): ReactElement { - return ; +type SettingsThemeChoice = AppTheme | "system"; + +function settingsValueEqual(left: unknown, right: unknown): boolean { + return Object.is(left, right) || JSON.stringify(left) === JSON.stringify(right); +} + +export function mergeConcurrentSpecificSettings( + base: AppSettings, + requested: AppSettings, + persisted: AppSettings, + current: AppSettings +): AppSettings { + const merged = { ...current } as Record; + for (const key of Object.keys(requested) as Array) { + if (!settingsValueEqual(base[key], requested[key]) && settingsValueEqual(base[key], current[key])) { + merged[key] = persisted[key]; + } + } + return merged as unknown as AppSettings; +} + +export function resolveSettingsThemeChoice(choice: SettingsThemeChoice, prefersLight: boolean): AppTheme { + return choice === "system" ? (prefersLight ? "light" : "dark") : choice; } function getAccountQuickActionMeta(kind: AccountKind): { label: string; action: AccountQuickAction } | null { @@ -673,16 +825,16 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n return { mode, kind, service, token: "", login: settings.ddownloadLogin, password: settings.ddownloadPassword, dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; case "onefichier-api": return { mode, kind, service, token: settings.oneFichierApiKey, login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; - case "debridlink-api": - return { + case "debridlink-api": + return { mode, kind, service, - token: settings.debridLinkApiKeys || "", + token: mode === "create" ? "" : settings.debridLinkApiKeys || "", login: "", password: "", dailyLimitGb, - keyDailyLimitGbById: buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings), + keyDailyLimitGbById: mode === "create" ? {} : buildDebridLinkKeyLimitInputs(settings.debridLinkApiKeys || "", undefined, settings), ...baseMega }; case "linksnappy-login": @@ -871,25 +1023,7 @@ const emptyStats = (): DownloadStats => ({ runtimeMeasuredAt: 0 }); -type StatsSectionItem = { - key: string; - eyebrow: string; - label: string; - value: string; - compactValue?: boolean; - danger?: boolean; - clickable?: boolean; - title?: string; - onClick?: () => void; -}; - -type StatsSection = { - key: string; - title: string; - items: StatsSectionItem[]; -}; - -const emptySnapshot = (): UiSnapshot => ({ +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: "", debridLinkDisabledKeyIds: [], @@ -946,20 +1080,7 @@ const historyRetentionLabels: Record = { - realdebrid: "Real-Debrid", - megadebrid: "Mega-Debrid", - "megadebrid-api": "Mega-Debrid API", - "megadebrid-web": "Mega-Debrid Web", - bestdebrid: "BestDebrid", - alldebrid: "AllDebrid", - ddownload: "DDownload", - onefichier: "1Fichier", - debridlink: "Debrid-Link", - linksnappy: "LinkSnappy" -}; - -const KNOWN_HOSTERS: { id: string; label: string }[] = [ +const KNOWN_HOSTERS: { id: string; label: string }[] = [ { id: "rapidgator", label: "Rapidgator" }, { id: "uploaded", label: "Uploaded" }, { id: "1fichier", label: "1Fichier" }, @@ -987,8 +1108,8 @@ const KNOWN_HOSTERS: { id: string; label: string }[] = [ { id: "frdl", label: "FreeDownload" }, { id: "hexupload", label: "HexUpload" }, { id: "isra", label: "Isra.cloud" } -]; - +]; + function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): string { const base = providerLabels[provider]; if (provider === "megadebrid" || provider === "megadebrid-api" || provider === "megadebrid-web") { @@ -1000,139 +1121,7 @@ function providerLabelWithMode(provider: DebridProvider, settings: AppSettings): return opt?.modeLabel ? `${base} (${opt.modeLabel})` : base; } -function compactProviderLabels(labels: string[]): string { - const unique = [...new Set(labels)]; - const groups = new Map(); - for (const label of unique) { - const m = label.match(/^(.+?)\s*\((.+)\)$/); - if (m) { - const arr = groups.get(m[1]) || []; - arr.push(m[2]); - groups.set(m[1], arr); - } else { - groups.set(label, []); - } - } - return [...groups.entries()].map(([base, details]) => - details.length === 0 ? base : `${base} (${details.join(" + ")})` - ).join(", "); -} - -function formatDateTime(ts: number): string { - if (!ts) return ""; - const d = new Date(ts); - const dd = String(d.getDate()).padStart(2, "0"); - const mm = String(d.getMonth() + 1).padStart(2, "0"); - const yyyy = d.getFullYear(); - const hh = String(d.getHours()).padStart(2, "0"); - const min = String(d.getMinutes()).padStart(2, "0"); - return `${dd}.${mm}.${yyyy} - ${hh}:${min}`; -} - -function extractHoster(url: string): string { - try { - const host = new URL(url).hostname.replace(/^www\./, ""); - const parts = host.split("."); - return parts.length >= 2 ? parts[parts.length - 2] : host; - } catch { return ""; } -} - -function formatAudioStripSummary(summary: AudioStripSummary): { text: string; tooltip: string; attention: boolean } { - const parts: string[] = []; - const ok = summary.remuxed + summary.keptSingle; - if (ok > 0) parts.push(`${ok} OK`); - if (summary.skippedNoGerman > 0) parts.push(`${summary.skippedNoGerman} ohne DE-Tag`); - if (summary.skippedNoTool > 0) parts.push("ffmpeg fehlt"); - if (summary.failed > 0) parts.push(`${summary.failed} Fehler`); - const tooltip = summary.files - .map((f) => `${f.name}: ${f.action} (${f.reason}${f.languages ? `, Spuren: ${f.languages}` : ""})`) - .join("\n"); - return { - text: `Tonspur: ${parts.join(" · ") || "—"}`, - tooltip, - attention: summary.skippedNoGerman > 0 || summary.skippedNoTool > 0 || summary.failed > 0 - }; -} - -const settingsSubTabs: { key: SettingsSubTab; label: string }[] = [ - { key: "allgemein", label: "Allgemein" }, - { key: "accounts", label: "Accounts" }, - { key: "entpacken", label: "Entpacken" }, - { key: "geschwindigkeit", label: "Geschwindigkeit" }, - { key: "bereinigung", label: "Bereinigung" }, - { key: "updates", label: "Updates" }, -]; - -function formatSpeedMbps(speedBps: number): string { - const mbps = Math.max(0, speedBps || 0) / (1024 * 1024); - return `${mbps.toFixed(2)} MB/s`; -} - -function humanSize(bytes: number): string { - if (!Number.isFinite(bytes) || bytes < 0) { - return "0 B"; - } - if (bytes < 1024) { return `${bytes} B`; } - if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } - if (bytes < 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } - if (bytes < 1024 * 1024 * 1024 * 1024) { return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; } - return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(3)} TB`; -} - -function formatRuntimeDuration(durationMs: number): string { - const totalSeconds = Math.max(0, Math.floor((durationMs || 0) / 1000)); - const minuteSeconds = 60; - const hourSeconds = 60 * minuteSeconds; - const daySeconds = 24 * hourSeconds; - const weekSeconds = 7 * daySeconds; - const monthSeconds = 30 * daySeconds; - - const formatUnit = (value: number, singular: string, plural: string, padTo = 0): string => { - const normalized = Math.max(0, Math.floor(value)); - const text = padTo > 0 ? String(normalized).padStart(padTo, "0") : String(normalized); - return `${text} ${normalized === 1 ? singular : plural}`; - }; - - if (totalSeconds < hourSeconds) { - const minutes = Math.floor(totalSeconds / minuteSeconds); - const seconds = totalSeconds % minuteSeconds; - return `${formatUnit(minutes, "Minute", "Minuten")}, ${formatUnit(seconds, "Sekunde", "Sekunden", 2)}`; - } - - if (totalSeconds < daySeconds) { - const hours = Math.floor(totalSeconds / hourSeconds); - const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); - return `${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten", 2)}`; - } - - if (totalSeconds < weekSeconds) { - const days = Math.floor(totalSeconds / daySeconds); - const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); - const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); - return `${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; - } - - if (totalSeconds < monthSeconds) { - const weeks = Math.floor(totalSeconds / weekSeconds); - const days = Math.floor((totalSeconds % weekSeconds) / daySeconds); - const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); - const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); - return `${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; - } - - const months = Math.floor(totalSeconds / monthSeconds); - const weeks = Math.floor((totalSeconds % monthSeconds) / weekSeconds); - const days = Math.floor((totalSeconds % weekSeconds) / daySeconds); - const hours = Math.floor((totalSeconds % daySeconds) / hourSeconds); - const minutes = Math.floor((totalSeconds % hourSeconds) / minuteSeconds); - return `${formatUnit(months, "Monat", "Monate")}, ${formatUnit(weeks, "Woche", "Wochen")}, ${formatUnit(days, "Tag", "Tage")}, ${formatUnit(hours, "Stunde", "Stunden")}, ${formatUnit(minutes, "Minute", "Minuten")}`; -} - -function formatHistoryDuration(durationSeconds: number): string { - return formatRuntimeDuration(Math.max(0, durationSeconds || 0) * 1000); -} - -function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string { +function formatAllDebridSourceLabel(source: AllDebridHostInfo["source"]): string { return source === "web" ? "Web-Login" : "API-Key"; } @@ -1304,32 +1293,19 @@ function getDebridLinkKeyStatusDisplay( }; } -function splitStatValue(value: string): { num: string; unit: string; idle: boolean } { - const v = (value ?? "").trim(); - if (v === "" || v === "--" || v === "—") return { num: "—", unit: "", idle: true }; - const match = v.match(/^(-?[\d.,]+)\s*(.*)$/); - if (!match) return { num: v, unit: "", idle: false }; - const num = match[1]; - const unit = (match[2] || "").trim(); - const idle = /^0([.,]0+)?$/.test(num); - return { num, unit, idle }; -} - -function StatValueView({ value, compact, danger }: { value: string; compact?: boolean; danger?: boolean }): ReactElement { - const { num, unit, idle } = splitStatValue(value); - const cls = `stat-value${compact ? " stat-value-compact" : ""}${danger ? " danger" : ""}${idle ? " stat-idle" : ""}`; - if (compact) { - return {idle ? "—" : value}; - } - return ( - - {num} - {unit ? {unit} : null} - - ); -} - -interface BandwidthChartProps { +export function readBandwidthChartPalette( + readProperty: (property: string) => string, + fontFamily: string +): { grid: string; text: string; accent: string; fontFamily: string } { + return { + grid: readProperty("--ui-border").trim(), + text: readProperty("--ui-text-muted").trim(), + accent: readProperty("--ui-accent").trim(), + fontFamily: fontFamily.trim() + }; +} + +interface BandwidthChartProps { items: Record; running: boolean; paused: boolean; @@ -1362,11 +1338,12 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp ctx.clearRect(0, 0, width, height); - const isDark = document.documentElement.getAttribute("data-theme") !== "light"; - const gridColor = isDark ? "rgba(35, 57, 84, 0.5)" : "rgba(199, 213, 234, 0.5)"; - const textColor = isDark ? "#90a4bf" : "#4e6482"; - const accentColor = isDark ? "#f2942d" : "#c2701a"; - const fillColor = isDark ? "rgba(242, 148, 45, 0.15)" : "rgba(194, 112, 26, 0.15)"; + const rootStyle = getComputedStyle(document.documentElement); + const bodyStyle = getComputedStyle(document.body); + const palette = readBandwidthChartPalette( + (property) => rootStyle.getPropertyValue(property), + bodyStyle.fontFamily || rootStyle.fontFamily + ); const history = speedHistoryRef.current; const now = Date.now(); @@ -1380,7 +1357,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp maxSpeed = Math.max(maxSpeed, 1024 * 1024); const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed))); - ctx.font = "11px 'Manrope', sans-serif"; + ctx.font = `11px ${palette.fontFamily}`; let maxLabelWidth = 0; for (let i = 0; i <= 5; i += 1) { const speedVal = niceMax * (1 - i / 5); @@ -1391,7 +1368,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp const chartWidth = width - padding.left - padding.right; const chartHeight = height - padding.top - padding.bottom; - ctx.strokeStyle = gridColor; + ctx.strokeStyle = palette.grid; ctx.lineWidth = 1; for (let i = 0; i <= 5; i += 1) { const y = padding.top + (chartHeight / 5) * i; @@ -1401,8 +1378,8 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp ctx.stroke(); } - ctx.fillStyle = textColor; - ctx.font = "11px 'Manrope', sans-serif"; + ctx.fillStyle = palette.text; + ctx.font = `11px ${palette.fontFamily}`; ctx.textAlign = "right"; ctx.textBaseline = "middle"; @@ -1419,8 +1396,8 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp ctx.fillText("0s", width - padding.right, height - padding.bottom + 8); if (history.length < 2) { - ctx.fillStyle = textColor; - ctx.font = "13px 'Manrope', sans-serif"; + ctx.fillStyle = palette.text; + ctx.font = `13px ${palette.fontFamily}`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText(running ? (paused ? "Pausiert" : "Sammle Daten...") : "Download starten für Statistiken", width / 2, height / 2); @@ -1442,22 +1419,25 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp ctx.lineTo(points[points.length - 1].x, padding.top + chartHeight); ctx.lineTo(points[0].x, padding.top + chartHeight); ctx.closePath(); - ctx.fillStyle = fillColor; - ctx.fill(); + ctx.save(); + ctx.globalAlpha = 0.15; + ctx.fillStyle = palette.accent; + ctx.fill(); + ctx.restore(); ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length; i += 1) { ctx.lineTo(points[i].x, points[i].y); } - ctx.strokeStyle = accentColor; + ctx.strokeStyle = palette.accent; ctx.lineWidth = 2; ctx.stroke(); const lastPoint = points[points.length - 1]; ctx.beginPath(); ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2); - ctx.fillStyle = accentColor; + ctx.fillStyle = palette.accent; ctx.fill(); }, [running, paused]); @@ -1667,21 +1647,9 @@ function computePackageProgress(pkg: PackageEntry | undefined, items: Record 0 ? totalDown / totalSize : 0; } -type PkgSortColumn = "name" | "size" | "hoster" | "progress"; - -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 COLUMN_DEFS: Record = { - 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" }, -}; +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 COLUMN_DEFS = downloadColumnDefinitions; function sameStringArray(a: string[], b: string[]): boolean { if (a.length !== b.length) { @@ -1712,7 +1680,7 @@ function parseMbpsInput(value: string): number | null { return parsed; } -function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { +function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { if (progress.stage === "downloading") { if (progress.totalBytes && progress.totalBytes > 0 && progress.percent !== null) { return `Update-Download: ${progress.percent}% (${humanSize(progress.downloadedBytes)} / ${humanSize(progress.totalBytes)})`; @@ -1731,23 +1699,48 @@ function formatUpdateInstallProgress(progress: UpdateInstallProgress): string { if (progress.stage === "done") { return "Installer gestartet"; } - return `Update-Fehler: ${progress.message}`; -} - -export function App(): ReactElement { + return `Update-Fehler: ${progress.message}`; +} + +export function shouldApplyUpdateCheckResult( + completedGeneration: number, + currentGeneration: number +): boolean { + return completedGeneration === currentGeneration; +} + +export async function runLatestUpdateCheck( + generationRef: { current: number }, + check: () => Promise, + apply: (result: UpdateCheckResult, generation: number) => Promise | void +): Promise { + const generation = ++generationRef.current; + const result = await check(); + if (!shouldApplyUpdateCheckResult(generation, generationRef.current)) { + return; + } + await apply(result, generation); +} + +export function App(): ReactElement { const [snapshot, setSnapshot] = useState(emptySnapshot); const [appVersion, setAppVersion] = useState(""); - const [tab, setTab] = useState("downloads"); - const [statusToast, setStatusToast] = useState(""); - const [updateInstallProgress, setUpdateInstallProgress] = useState(null); - const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const [tab, setTab] = useState("downloads"); + const [statusToast, setStatusToast] = useState(""); + const [availableUpdate, setAvailableUpdate] = useState(null); + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + const [updateInstallProgress, setUpdateInstallProgress] = useState(null); + const [settingsDraft, setSettingsDraft] = useState(emptySnapshot().settings); + const [settingsThemeChoice, setSettingsThemeChoice] = useState(emptySnapshot().settings.theme); const [speedLimitInput, setSpeedLimitInput] = useState(() => formatMbpsInputFromKbps(emptySnapshot().settings.speedLimitKbps)); const [scheduleSpeedInputs, setScheduleSpeedInputs] = useState>({}); - const [settingsDirty, setSettingsDirty] = useState(false); + const [settingsDirty, setSettingsDirty] = useState(false); + const [settingsSaveState, setSettingsSaveState] = useState("clean"); const [schedulePickerOpen, setSchedulePickerOpen] = useState(false); const [scheduleTimeInput, setScheduleTimeInput] = useState(""); const [scheduleCountdown, setScheduleCountdown] = useState(""); - const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); + const [runtimeNow, setRuntimeNow] = useState(() => Date.now()); + const updateCheckGenerationRef = useRef(0); const settingsDirtyRef = useRef(false); const settingsDraftRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0); @@ -1767,11 +1760,15 @@ export function App(): ReactElement { const [providerDropTarget, setProviderDropTarget] = useState(null); const [editingPackageId, setEditingPackageId] = useState(null); const [editingName, setEditingName] = useState(""); - const [collectorTabs, setCollectorTabs] = useState([ - { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } - ]); - const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); - const collectorTabsRef = useRef(collectorTabs); + const [collectorTabs, setCollectorTabs] = useState([ + { id: `tab-${nextCollectorId++}`, name: "Tab 1", text: "" } + ]); + const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id); + const [collectorQuery, setCollectorQuery] = useState(""); + const [selectedCollectorRowIds, setSelectedCollectorRowIds] = useState>(() => new Set()); + const [collectorError, setCollectorError] = useState(""); + const [collectorInput, setCollectorInput] = useState(null); + const collectorTabsRef = useRef(collectorTabs); const activeCollectorTabRef = useRef(activeCollectorTab); const activeTabRef = useRef(tab); const packageOrderRef = useRef([]); @@ -1779,9 +1776,12 @@ export function App(): ReactElement { const pendingPackageOrderRef = useRef(null); const pendingPackageOrderAtRef = useRef(0); const draggedPackageIdRef = useRef(null); - const [collapsedPackages, setCollapsedPackages] = useState>({}); - const [downloadSearch, setDownloadSearch] = useState(""); - const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); + const [collapsedPackages, setCollapsedPackages] = useState>({}); + const [downloadSearch, setDownloadSearch] = useState(""); + const [downloadDisplayMode, setDownloadDisplayMode] = useState("packages"); + const [downloadFilter, setDownloadFilter] = useState("all"); + const [downloadProviderFilter, setDownloadProviderFilter] = useState("all"); + const [downloadsSortColumn, setDownloadsSortColumn] = useState("name"); const [downloadsSortDescending, setDownloadsSortDescending] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false); const [actionBusy, setActionBusy] = useState(false); @@ -1794,7 +1794,9 @@ export function App(): ReactElement { const dragOverRef = useRef(false); const dragDepthRef = useRef(0); const [openMenu, setOpenMenu] = useState(null); - const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); + const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [settingsSubTab, setSettingsSubTab] = useState("allgemein"); const [accountManagementTab, setAccountManagementTab] = useState<"overview" | "rules">("overview"); const [selectedAccountRowKey, setSelectedAccountRowKey] = useState(null); const [openSubmenu, setOpenSubmenu] = useState(null); @@ -1833,33 +1835,23 @@ export function App(): ReactElement { const [dropTargetCol, setDropTargetCol] = useState(null); const [colHeaderCtx, setColHeaderCtx] = useState<{ x: number; y: number } | null>(null); const colHeaderCtxRef = useRef(null); - const colHeaderBarRef = useRef(null); - const [historyEntries, setHistoryEntries] = useState([]); - const historyEntriesRef = useRef([]); - const [historyCollapsed, setHistoryCollapsed] = useState>({}); - const [selectedHistoryIds, setSelectedHistoryIds] = useState>(new Set()); - const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null); - const historyCtxMenuRef = useRef(null); - const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); - const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); - const allDebridHostRequestRef = useRef(0); - const debridLinkHostLimitsRequestRef = useRef(0); - useEffect(() => { - if (tab !== "history") return; - const loadHistory = async (): Promise => { - try { - const entries = await window.rd.getHistory(); - if (mountedRef.current && entries) { - setHistoryEntries(entries); - } - } catch (err) { - console.error("Failed to load history:", err); - } - }; - void loadHistory(); - }, [tab]); - - useEffect(() => { historyEntriesRef.current = historyEntries; }, [historyEntries]); + const [historyEntries, setHistoryEntries] = useState([]); + const historyEntriesRef = useRef([]); + const [historyExpandedIds, setHistoryExpandedIds] = useState>(() => new Set()); + const [selectedHistoryIds, setSelectedHistoryIds] = useState>(new Set()); + const [historyFilter, setHistoryFilter] = useState("all"); + const [historyQuery, setHistoryQuery] = useState(""); + const [statisticsRange, setStatisticsRange] = useState("session"); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(""); + const [historyCtxMenu, setHistoryCtxMenu] = useState<{ x: number; y: number; entryId: string } | null>(null); + const historyCtxMenuRef = useRef(null); + const historyLoadGenerationRef = useRef(0); + const historyVisibleIdsRef = useRef([]); + const [allDebridHostInfo, setAllDebridHostInfo] = useState(null); + const [allDebridHostLoading, setAllDebridHostLoading] = useState(false); + const allDebridHostRequestRef = useRef(0); + const debridLinkHostLimitsRequestRef = useRef(0); const columnOrderKey = useMemo( () => (snapshot.settings.columnOrder || []).join("|"), @@ -1872,7 +1864,30 @@ export function App(): ReactElement { } }, [columnOrderKey]); - const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; + const collectorViewModel = useMemo(() => buildCollectorViewModel( + collectorTabs, + activeCollectorTab, + collectorQuery, + actionBusy, + [...selectedCollectorRowIds], + collectorError + ), [actionBusy, activeCollectorTab, collectorError, collectorQuery, collectorTabs, selectedCollectorRowIds]); + + const historyViewModel = useMemo(() => buildHistoryViewModel( + historyEntries, + historyFilter, + historyQuery, + selectedHistoryIds, + historyExpandedIds, + historyLoading, + historyError, + runtimeNow + ), [historyEntries, historyError, historyExpandedIds, historyFilter, historyLoading, historyQuery, runtimeNow, selectedHistoryIds]); + historyVisibleIdsRef.current = historyViewModel.rows.map((entry) => entry.id); + const statisticsViewModel = useMemo( + () => buildStatisticsViewModel(snapshot, statisticsRange, runtimeNow), + [runtimeNow, snapshot, statisticsRange] + ); useEffect(() => { activeCollectorTabRef.current = activeCollectorTab; @@ -1940,16 +1955,57 @@ export function App(): ReactElement { return () => clearInterval(timer); }, []); - const showToast = useCallback((message: string, timeoutMs = 2200): void => { + const showToast = useCallback((message: string, timeoutMs = 2200): void => { setStatusToast(message); if (toastTimerRef.current) { clearTimeout(toastTimerRef.current); } toastTimerRef.current = setTimeout(() => { setStatusToast(""); toastTimerRef.current = null; - }, timeoutMs); - }, []); - - const loadAllDebridHostInfo = useCallback(async (silent = false): Promise => { + }, timeoutMs); + }, []); + + const applyHistoryEntries = useCallback((entries: HistoryEntry[]): void => { + const availableIds = entries.map((entry) => entry.id); + const availableSet = new Set(availableIds); + historyEntriesRef.current = entries; + setHistoryEntries(entries); + setSelectedHistoryIds((current) => pruneHistoryIds(current, availableIds)); + setHistoryExpandedIds((current) => pruneHistoryIds(current, availableIds)); + setHistoryCtxMenu((current) => current && availableSet.has(current.entryId) ? current : null); + }, []); + + const loadHistoryEntries = useCallback(async (): Promise => { + const generation = ++historyLoadGenerationRef.current; + setHistoryLoading(true); + setHistoryError(""); + try { + const entries = await window.rd.getHistory(); + if (!mountedRef.current || generation !== historyLoadGenerationRef.current) { + return; + } + applyHistoryEntries(entries); + } catch { + if (mountedRef.current && generation === historyLoadGenerationRef.current) { + setHistoryError("Verlauf konnte nicht geladen werden"); + } + } finally { + if (mountedRef.current && generation === historyLoadGenerationRef.current) { + setHistoryLoading(false); + } + } + }, [applyHistoryEntries]); + + useEffect(() => { + if (tab !== "history") { + return; + } + void loadHistoryEntries(); + return () => { + historyLoadGenerationRef.current += 1; + }; + }, [loadHistoryEntries, tab]); + + const loadAllDebridHostInfo = useCallback(async (silent = false): Promise => { const requestId = allDebridHostRequestRef.current + 1; allDebridHostRequestRef.current = requestId; setAllDebridHostLoading(true); @@ -2031,8 +2087,9 @@ export function App(): ReactElement { document.title = `Multi Debrid Downloader${appVersion ? ` - v${appVersion}` : ""}`; }, [appVersion]); - useEffect(() => { - let unsubscribe: (() => void) | null = null; + useEffect(() => { + mountedRef.current = true; + let unsubscribe: (() => void) | null = null; let unsubClipboard: (() => void) | null = null; let unsubUpdateInstallProgress: (() => void) | null = null; void window.rd.getVersion().then((v) => { if (mountedRef.current) { setAppVersion(v); } }).catch(() => undefined); @@ -2051,17 +2108,18 @@ export function App(): ReactElement { setColumnOrder(state.settings.columnOrder); } setSettingsDraft(state.settings); - settingsDirtyRef.current = false; - panelDirtyRevisionRef.current = 0; - setSettingsDirty(false); - applyTheme(state.settings.theme); - if (state.settings.autoUpdateCheck) { - void window.rd.checkUpdates().then((result) => { - if (!mountedRef.current) { - return; - } - void handleUpdateResult(result, "startup"); - }).catch(() => undefined); + settingsDirtyRef.current = false; + panelDirtyRevisionRef.current = 0; + setSettingsDirty(false); + setSettingsSaveState("clean"); + setSettingsThemeChoice(state.settings.theme); + applyTheme(state.settings.theme); + if (state.settings.autoUpdateCheck) { + void runLatestUpdateCheck( + updateCheckGenerationRef, + () => window.rd.checkUpdates(), + (result, generation) => handleUpdateResult(result, "startup", generation) + ).catch(() => undefined); } }).catch((error) => { showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800); @@ -2164,53 +2222,26 @@ export function App(): ReactElement { }; }, [clearImportQueueFocusListener]); - const downloadsTabActive = tab === "downloads"; - const deferredDownloadSearch = useDeferredValue(downloadSearch); - const downloadSearchQuery = deferredDownloadSearch.trim().toLowerCase(); - const downloadSearchActive = downloadSearchQuery.length > 0; - const gridTemplate = useMemo(() => columnOrder.map((col) => COLUMN_DEFS[col]?.width ?? "100px").join(" "), [columnOrder]); - const totalPackageCount = snapshot.session.packageOrder.length; - const shouldLimitPackageRendering = downloadsTabActive - && snapshot.session.running - && !downloadSearchActive - && totalPackageCount > AUTO_RENDER_PACKAGE_LIMIT - && !showAllPackages; - - const packageIdsForView = useMemo(() => { - if (!downloadsTabActive) { - return [] as string[]; - } - if (downloadSearchActive) { - return snapshot.session.packageOrder; - } - if (shouldLimitPackageRendering) { - return snapshot.session.packageOrder.slice(0, AUTO_RENDER_PACKAGE_LIMIT); - } - return snapshot.session.packageOrder; - }, [downloadsTabActive, downloadSearchActive, shouldLimitPackageRendering, snapshot.session.packageOrder]); - - const packageOrderKey = useMemo(() => { - if (!downloadsTabActive) { - return ""; - } - return packageIdsForView.join("|"); - }, [downloadsTabActive, packageIdsForView]); - - const packages = useMemo(() => { - if (!downloadsTabActive) { - return [] as PackageEntry[]; - } - - if (downloadSearchActive) { - return snapshot.session.packageOrder - .map((id: string) => snapshot.session.packages[id]) - .filter((pkg): pkg is PackageEntry => Boolean(pkg) && pkg.name.toLowerCase().includes(downloadSearchQuery)); - } - - return packageIdsForView - .map((id) => snapshot.session.packages[id]) - .filter((pkg): pkg is PackageEntry => Boolean(pkg)); - }, [downloadsTabActive, downloadSearchActive, downloadSearchQuery, packageIdsForView, snapshot.session.packageOrder, snapshot.session.packages]); + const downloadsTabActive = tab === "downloads"; + const deferredDownloadSearch = useDeferredValue(downloadSearch); + const gridTemplate = useMemo(() => columnOrder.map((col) => COLUMN_DEFS[col]?.width ?? "100px").join(" "), [columnOrder]); + const totalPackageCount = snapshot.session.packageOrder.length; + + const packageOrderKey = useMemo(() => { + if (!downloadsTabActive) { + return ""; + } + return snapshot.session.packageOrder.join("|"); + }, [downloadsTabActive, snapshot.session.packageOrder]); + + const packages = useMemo(() => { + if (!downloadsTabActive) { + return [] as PackageEntry[]; + } + return snapshot.session.packageOrder + .map((id) => snapshot.session.packages[id]) + .filter((pkg): pkg is PackageEntry => Boolean(pkg)); + }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages]); const packagePosition = useMemo(() => { if (!downloadsTabActive) { @@ -2223,20 +2254,6 @@ export function App(): ReactElement { return map; }, [downloadsTabActive, snapshot.session.packageOrder]); - const itemsByPackage = useMemo(() => { - if (!downloadsTabActive) { - return new Map(); - } - const map = new Map(); - for (const pkg of packages) { - const items = pkg.itemIds - .map((id) => snapshot.session.items[id]) - .filter(Boolean) as DownloadItem[]; - map.set(pkg.id, items); - } - return map; - }, [downloadsTabActive, packageOrderKey, packages, snapshot.session.items]); - useEffect(() => { if (!downloadsTabActive) { return; @@ -2273,13 +2290,10 @@ export function App(): ReactElement { setSelectedIds((prev) => pruneSelection(prev, snapshot.session)); }, [snapshot.session.packages, snapshot.session.items]); - const hiddenPackageCount = shouldLimitPackageRendering - ? Math.max(0, totalPackageCount - packages.length) - : 0; - const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) + const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) ? snapshot.session.items : null; - const visiblePackages = useMemo(() => { + const visiblePackages = useMemo(() => { if (!sortRelevantItems) { return packages; } @@ -2289,7 +2303,22 @@ export function App(): ReactElement { true, true ); - }, [packages, sortRelevantItems]); + }, [packages, sortRelevantItems]); + + const downloadsViewCore = useMemo(() => buildDownloadsViewModel({ + packageOrder: visiblePackages.map((entry) => entry.id), + packages: snapshot.session.packages, + items: snapshot.session.items, + displayMode: downloadDisplayMode, + filter: downloadFilter, + providerFilter: downloadProviderFilter, + query: deferredDownloadSearch, + collapsedPackageIds: Object.entries(collapsedPackages).filter(([, value]) => value).map(([id]) => id), + selectedIds, + hideExtractedItems: snapshot.settings.hideExtractedItems, + showAllPackages: showAllPackages || !snapshot.session.running, + renderLimit: AUTO_RENDER_PACKAGE_LIMIT + }), [collapsedPackages, deferredDownloadSearch, downloadDisplayMode, downloadFilter, downloadProviderFilter, selectedIds, showAllPackages, snapshot.session.items, snapshot.session.packages, snapshot.session.running, snapshot.settings.hideExtractedItems, visiblePackages]); const hasSavedAllDebridAccount = Boolean(snapshot.settings.allDebridUseWebLogin || snapshot.settings.allDebridToken.trim()); const allDebridSettingsDirty = snapshot.settings.allDebridUseWebLogin !== settingsDraft.allDebridUseWebLogin @@ -2363,10 +2392,11 @@ export function App(): ReactElement { const setProviderOrder = useCallback((newOrder: DebridProvider[]) => { settingsDraftRevisionRef.current += 1; - panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); - setSettingsDraft((prev) => ({ + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); + setSettingsDraft((prev) => ({ ...prev, providerOrder: newOrder, providerPrimary: newOrder[0] ?? prev.providerPrimary, @@ -2375,14 +2405,14 @@ export function App(): ReactElement { })); }, []); - const onProviderDragStart = useCallback((event: DragEvent, provider: DebridProvider): void => { + const onProviderDragStart = useCallback((event: DragEvent, provider: DebridProvider): void => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", provider); setDraggedProvider(provider); setProviderDropTarget(provider); }, []); - const onProviderDragOver = useCallback((event: DragEvent, provider: DebridProvider): void => { + const onProviderDragOver = useCallback((event: DragEvent, provider: DebridProvider): void => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; if (providerDropTarget !== provider) { @@ -2390,7 +2420,7 @@ export function App(): ReactElement { } }, [providerDropTarget]); - const onProviderDrop = useCallback((event: DragEvent, provider: DebridProvider): void => { + const onProviderDrop = useCallback((event: DragEvent, provider: DebridProvider): void => { event.preventDefault(); if (!draggedProvider || draggedProvider === provider) { return; @@ -2632,119 +2662,14 @@ export function App(): ReactElement { const [accountStatusSort, setAccountStatusSort] = useState<"none" | "desc" | "asc">("none"); const cycleAccountStatusSort = (): void => setAccountStatusSort((s) => (s === "none" ? "desc" : s === "desc" ? "asc" : "none")); - const sortedAccountRows = useMemo(() => { - if (accountStatusSort === "none") return accountRows; - const statuses = snapshot.settings?.debridAccountStatuses || {}; - const now = Date.now(); - const remainingMs = (row: (typeof accountRows)[number]): number => { - const status = row.accountId ? statuses[row.accountId] : null; - return status && status.premiumUntilMs && status.premiumUntilMs > now ? status.premiumUntilMs - now : -1; - }; - return [...accountRows].sort((a, b) => { - const remainingA = remainingMs(a); - const remainingB = remainingMs(b); - if (remainingA > 0 && remainingB > 0) return accountStatusSort === "desc" ? remainingB - remainingA : remainingA - remainingB; - if (remainingA > 0) return -1; - if (remainingB > 0) return 1; - return 0; - }); - }, [accountRows, accountStatusSort, snapshot.settings]); - useEffect(() => { setSelectedAccountRowKey((current) => pruneAccountRowSelection(current, accountRows.map((row) => row.rowKey))); }, [accountRows]); - - const renderAccountRow = (row: AccountTableRow): ReactElement => { - const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null; - const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false; - let statusCls = "none"; - let statusText = "—"; - if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; } - else if (!row.checkable) { - statusCls = row.entry.statusLabel === "Aktiviert" ? "unknown" : "ok"; - statusText = row.entry.statusLabel === "Aktiviert" ? "Aktiv" : row.entry.statusLabel; - } - else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; } - else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; } - else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; } - else if (!st.isPremium) { statusCls = "free"; statusText = "Free Account"; } - else { statusCls = "ok"; statusText = st.message || "Premium Account"; } - const isProblem = statusCls === "invalid"; - const username = resolveAccountUsername(row.username, st?.email); - const usernameTitle = username; - const expiry = st && st.premiumUntilMs && st.premiumUntilMs > 0 ? new Date(st.premiumUntilMs).toLocaleDateString("de-DE") : "—"; - const traffic = row.dailyLimitBytes > 0 - ? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig` - : "Unbeschränkt"; - return ( -
setSelectedAccountRowKey(row.rowKey)} - onKeyDown={(event) => { - if (isAccountRowSelectionKey(event.key, event.target === event.currentTarget)) { - event.preventDefault(); - setSelectedAccountRowKey(row.rowKey); - } - }} - onDoubleClick={() => openEditAccountDialog(row)} - onContextMenu={(event) => { - event.preventDefault(); - event.stopPropagation(); - setAccountContextMenu({ x: event.clientX, y: event.clientY, row }); - }} - > - - { - if (row.toggleKind === "mega" && row.megaLogin) { void onToggleMegaAccountEnabled(row.megaLogin, row.disabled); } - else if (row.toggleKind === "dl" && row.dlKey) { void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey); } - else { void onToggleAccountEnabled(row.entry); } - }} - /> - - - - {row.hosterLabel} - {row.modeLabel} - - - {statusCls === "none" - ? - : {statusText}} - - {traffic} - {username} - {expiry} - {row.credentialLabel} - - - -
- ); - }; const availableAccountOptions = useMemo(() => ( - ACCOUNT_OPTIONS.filter((option) => !configuredAccountServices.has(option.service)) + ACCOUNT_OPTIONS.filter((option) => option.kind === "megadebrid-api" + || option.kind === "megadebrid-web" + || option.kind === "debridlink-api" + || !configuredAccountServices.has(option.service)) ), [configuredAccountServices]); const accountEditOption = accountEditDialog ? findAccountOption(accountEditDialog.target.kind) : null; const accountEditRow = accountEditDialog ? accountRows.find((row) => row.rowKey === accountEditDialog.target.rowKey) ?? null : null; @@ -2765,7 +2690,10 @@ export function App(): ReactElement { const accountDialogSearchQuery = accountDialogSearch.trim().toLowerCase(); const filteredAccountDialogOptions = useMemo(() => ( accountDialogSelectableOptions.filter((option) => { - if (!matchesAccountModeFilter(option, accountDialogModeFilter)) { + const matchesMode = accountDialogModeFilter === "all" + || (accountDialogModeFilter === "api" && option.modeLabel === "API") + || (accountDialogModeFilter === "web" && (option.modeLabel.startsWith("Web") || option.kind === "ddownload-login" || option.kind === "linksnappy-login")); + if (!matchesMode) { return false; } if (!accountDialogSearchQuery) { @@ -2781,18 +2709,24 @@ export function App(): ReactElement { return haystack.includes(accountDialogSearchQuery); }) ), [accountDialogModeFilter, accountDialogSearchQuery, accountDialogSelectableOptions]); - const handleUpdateResult = async (result: UpdateCheckResult, source: "manual" | "startup"): Promise => { - if (!mountedRef.current) { - return; - } + const handleUpdateResult = async ( + result: UpdateCheckResult, + source: "manual" | "startup", + generation: number + ): Promise => { + if (!mountedRef.current || !shouldApplyUpdateCheckResult(generation, updateCheckGenerationRef.current)) { + return; + } if (result.error) { if (source === "manual") { showToast(`Update-Check fehlgeschlagen: ${result.error}`, 2800); } return; } - if (!result.updateAvailable) { - setUpdateInstallProgress(null); - if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } - return; + if (!result.updateAvailable) { + setAvailableUpdate(null); + setUpdateDialogOpen(false); + setUpdateInstallProgress(null); + if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } + return; } let changelogText = ""; if (result.releaseNotes) { @@ -2804,50 +2738,78 @@ export function App(): ReactElement { .replace(/\*([^*]+)\*/g, "$1") .replace(/`([^`]+)`/g, "$1")) .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(); - } - const approved = await askConfirmPrompt({ - title: "Update verfügbar", - message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, - confirmLabel: "Jetzt installieren", - details: changelogText || undefined - }); - if (!mountedRef.current) { - return; - } - if (!approved) { showToast(`Update verfügbar: ${result.latestTag}`, 2600); return; } - setUpdateInstallProgress({ - stage: "starting", - percent: 0, + .replace(/\n{3,}/g, "\n\n") + .trim(); + } + setAvailableUpdate({ + ...result, + releaseNotes: changelogText + }); + setUpdateInstallProgress(null); + setUpdateDialogOpen(true); + }; + + const installUpdate = async (): Promise => { + if (!availableUpdate) { + return; + } + setUpdateDialogOpen(true); + setUpdateInstallProgress({ + stage: "starting", + percent: 0, downloadedBytes: 0, - totalBytes: null, - message: "Update wird vorbereitet" - }); - const install = await window.rd.installUpdate(); - if (!mountedRef.current) { - return; - } - if (install.started) { showToast("Stilles Update gestartet - App wird neu gestartet", 2600); return; } - setUpdateInstallProgress({ - stage: "error", - percent: null, - downloadedBytes: 0, - totalBytes: null, - message: install.message - }); - showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); - }; + totalBytes: null, + message: "Update wird vorbereitet" + }); + try { + const install = await window.rd.installUpdate(); + if (!mountedRef.current) { + return; + } + if (install.started) { + showToast("Stilles Update gestartet - App wird neu gestartet", 2600); + return; + } + setUpdateInstallProgress({ + stage: "error", + percent: null, + downloadedBytes: 0, + totalBytes: null, + message: install.message + }); + showToast(`Auto-Update fehlgeschlagen: ${install.message}`, 3200); + } catch (error) { + if (!mountedRef.current) { + return; + } + const message = String(error); + setUpdateInstallProgress({ + stage: "error", + percent: null, + downloadedBytes: 0, + totalBytes: null, + message + }); + showToast(`Auto-Update fehlgeschlagen: ${message}`, 3200); + } + }; - const onSaveSettings = async (): Promise => { - await performQuickAction(async () => { - const result = await persistDraftSettings(); - applyTheme(result.theme); - showToast("Einstellungen gespeichert", 1800); - }, (error) => { - showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); - }); - }; + const onSaveSettings = async (): Promise => { + if (actionBusyRef.current) { + return; + } + const revisionAtStart = settingsDraftRevisionRef.current; + setSettingsSaveState("saving"); + await performQuickAction(async () => { + const result = await persistDraftSettings(); + applyTheme(result.theme); + setSettingsSaveState(settingsDraftRevisionRef.current === revisionAtStart ? "saved" : "dirty"); + showToast("Einstellungen gespeichert", 1800); + }, (error) => { + setSettingsSaveState("error"); + showToast(`Einstellungen konnten nicht gespeichert werden: ${String(error)}`, 2800); + }); + }; const onOpenRealDebridLogin = async (): Promise => { await performQuickAction(async () => { @@ -2883,13 +2845,15 @@ export function App(): ReactElement { }); }; - const applyPersistedSettings = (result: AppSettings): void => { - setSettingsDraft(result); - settingsDirtyRef.current = false; - panelDirtyRevisionRef.current = 0; - setSettingsDirty(false); - applyTheme(result.theme); - }; + const applyPersistedSettings = (result: AppSettings): void => { + setSettingsDraft(result); + settingsDirtyRef.current = false; + panelDirtyRevisionRef.current = 0; + setSettingsDirty(false); + setSettingsSaveState("clean"); + setSettingsThemeChoice((current) => current === "system" ? current : result.theme); + applyTheme(result.theme); + }; const syncLiveProviderUsageSettings = (result: AppSettings): void => { setSnapshot((prev) => ({ ...prev, settings: result })); @@ -2910,15 +2874,24 @@ export function App(): ReactElement { })); }; - const persistSpecificSettings = async (nextDraft: AppSettings): Promise => { - const normalizedDraft = { - ...nextDraft, - ...normalizeProviderSelectionForSettings(nextDraft) - }; - const result = await window.rd.updateSettings(normalizedDraft); - applyPersistedSettings(result); - return result; - }; + const persistSpecificSettings = async (nextDraft: AppSettings): Promise => { + const revisionAtStart = settingsDraftRevisionRef.current; + const draftAtStart = settingsDraft; + const normalizedDraft = { + ...nextDraft, + ...normalizeProviderSelectionForSettings(nextDraft) + }; + const result = await window.rd.updateSettings(normalizedDraft); + if (settingsDraftRevisionRef.current === revisionAtStart) { + applyPersistedSettings(result); + } else { + setSettingsDraft((current) => mergeConcurrentSpecificSettings(draftAtStart, normalizedDraft, result, current)); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); + } + return result; + }; const runAccountQuickAction = async (action: AccountQuickAction): Promise => { switch (action) { @@ -2994,26 +2967,7 @@ export function App(): ReactElement { }; const updateAccountDialogKind = useCallback((kind: AccountKind): void => { - setAccountDialog((prev) => { - const next = createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft); - if (!prev) { - return next; - } - if (findAccountOption(kind).needsToken) { - next.token = prev.token; - } - if (findAccountOption(kind).needsCredentials) { - next.login = prev.login; - next.password = prev.password; - } - if (kind === "megadebrid-api" || kind === "megadebrid-web") { - next.megaAccounts = prev.megaAccounts; - next.megaNewLogin = prev.megaNewLogin; - next.megaNewPassword = prev.megaNewPassword; - } - next.dailyLimitGb = prev.dailyLimitGb; - return next; - }); + setAccountDialog((prev) => createAccountDialogState(prev?.mode ?? "create", kind, settingsDraft)); }, [settingsDraft]); useEffect(() => { @@ -3031,7 +2985,7 @@ export function App(): ReactElement { updateAccountDialogKind(visibleKind); return; } - setAccountDialog((current) => current ? { ...current, kind: null } : current); + setAccountDialog((current) => current ? createAccountDialogState(current.mode, null, settingsDraft) : current); }, [accountDialog?.kind, filteredAccountDialogOptions, updateAccountDialogKind]); const closeAccountDialog = useCallback((): void => { @@ -3076,31 +3030,82 @@ export function App(): ReactElement { }); }; - const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise => { - if (!accountDialog) { - return; - } - const validationError = validateAccountDialog(accountDialog); + const onSaveAccountDialog = async (quickAction?: AccountQuickAction): Promise => { + if (!accountDialog) { + return; + } + let dialogSnapshot = accountDialog; + let newIdentityId: string | null = null; + let newMegaCredentials: { login: string; password: string } | null = null; + let newDebridLinkToken = ""; + if (dialogSnapshot.mode === "create" && (dialogSnapshot.kind === "megadebrid-api" || dialogSnapshot.kind === "megadebrid-web")) { + const login = dialogSnapshot.megaNewLogin.trim(); + const password = dialogSnapshot.megaNewPassword; + if (!login || !password) { + showToast("Mega-Debrid: Bitte Login und Passwort eintragen.", 2800); + return; + } + if (dialogSnapshot.megaAccounts.some((account) => account.login.trim().toLowerCase() === login.toLowerCase())) { + showToast("Dieser Mega-Debrid-Account ist bereits vorhanden.", 2800); + return; + } + newIdentityId = getMegaDebridAccountId(login); + newMegaCredentials = { login, password }; + dialogSnapshot = { + ...dialogSnapshot, + megaAccounts: [...dialogSnapshot.megaAccounts, newMegaCredentials], + megaNewLogin: "", + megaNewPassword: "" + }; + } + if (dialogSnapshot.mode === "create" && dialogSnapshot.kind === "debridlink-api") { + const newKeys = parseDebridLinkApiKeys(dialogSnapshot.token); + if (newKeys.length !== 1) { + showToast("Debrid-Link: Bitte genau einen API-Key eintragen.", 2800); + return; + } + const existingKeys = parseDebridLinkApiKeys(settingsDraft.debridLinkApiKeys || ""); + if (existingKeys.some((key) => key.id === newKeys[0].id)) { + showToast("Dieser Debrid-Link-Key ist bereits vorhanden.", 2800); + return; + } + newIdentityId = newKeys[0].id; + newDebridLinkToken = newKeys[0].token; + dialogSnapshot = { + ...dialogSnapshot, + token: [...existingKeys.map((key) => key.token), newKeys[0].token].join("\n"), + keyDailyLimitGbById: { + ...buildDebridLinkKeyLimitInputs(settingsDraft.debridLinkApiKeys || "", undefined, settingsDraft), + [newKeys[0].id]: accountDialog.dailyLimitGb + } + }; + } + const validationError = validateAccountDialog(dialogSnapshot); if (validationError) { showToast(validationError, 2800); return; } - const dialogSnapshot = accountDialog; const selectedOption = dialogSnapshot.kind ? findAccountOption(dialogSnapshot.kind) : null; await performQuickAction(async () => { const nextDraft = applyAccountDialogToSettings(settingsDraft, dialogSnapshot); - const requiresCredentialCheck = selectedOption?.kind === "megadebrid-api" - || selectedOption?.kind === "megadebrid-web" - || selectedOption?.kind === "debridlink-api"; - if (requiresCredentialCheck) { - const statuses = await window.rd.checkDebridAccounts(nextDraft); - const invalidStatuses = statuses.filter((status) => !status.valid); - if (invalidStatuses.length > 0) { - const details = invalidStatuses - .map((status) => status.message || "Zugangsdaten ungültig") - .join(" | "); - showToast(`Prüfung fehlgeschlagen: ${details}`, 4200); - return; + const targetedOption = selectedOption && newIdentityId ? { + service: selectedOption.service === "megadebrid-web" ? "megadebrid-api" : selectedOption.service + } as Pick : null; + const targetedCheck = targetedOption && newIdentityId ? buildTargetedAccountCheck(targetedOption, newIdentityId) : null; + if (targetedCheck) { + const checkSettings = targetedCheck.service === "megadebrid-api" && newMegaCredentials + ? { + ...nextDraft, + megaCredentials: serializeMegaDebridAccounts([newMegaCredentials]), + megaLogin: newMegaCredentials.login, + megaPassword: newMegaCredentials.password, + debridLinkApiKeys: "" + } + : { ...nextDraft, megaCredentials: "", megaLogin: "", megaPassword: "", debridLinkApiKeys: newDebridLinkToken }; + const statuses = await window.rd.checkDebridAccounts(checkSettings, true, targetedCheck.expectedStatusId); + const status = statuses.length === 1 && statuses[0].accountId === targetedCheck.expectedStatusId ? statuses[0] : null; + if (!status || !status.valid) { + throw new Error(status?.message || "Die Prüfung hat nicht genau den neuen Account bestätigt."); } } await persistSpecificSettings(nextDraft); @@ -3328,15 +3333,14 @@ export function App(): ReactElement { }; const onCheckUpdates = async (): Promise => { - let updateResult: UpdateCheckResult | null = null; - await performQuickAction(async () => { - setUpdateInstallProgress(null); - updateResult = await window.rd.checkUpdates(); - }, (error) => { - showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); - }); - if (updateResult) await handleUpdateResult(updateResult, "manual"); - }; + await performQuickAction(() => runLatestUpdateCheck( + updateCheckGenerationRef, + () => window.rd.checkUpdates(), + (result, generation) => handleUpdateResult(result, "manual", generation) + ), (error) => { + showToast(`Update-Check fehlgeschlagen: ${String(error)}`, 2800); + }); + }; const persistDraftSettings = async (): Promise => { const revisionAtStart = settingsDraftRevisionRef.current; @@ -3388,14 +3392,147 @@ export function App(): ReactElement { pumpConfirmQueue(); }, [pumpConfirmQueue]); - const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise => { + const askConfirmPrompt = useCallback((prompt: ConfirmPromptState): Promise => { return new Promise((resolve) => { confirmQueueRef.current.push({ prompt, resolve }); pumpConfirmQueue(); - }); - }, [pumpConfirmQueue]); - - const onStartDownloads = async (): Promise => { + }); + }, [pumpConfirmQueue]); + + const restoreHistoryEntries = useCallback(async (entryIds: string[]): Promise => { + const requested = new Set(entryIds); + const entries = historyEntriesRef.current.filter((entry) => requested.has(entry.id)); + const urls = entries.flatMap((entry) => entry.urls ?? []); + if (urls.length === 0) { + showToast("Keine gespeicherten Links vorhanden"); + return; + } + try { + const result = await window.rd.addLinks({ + rawText: urls.join("\n"), + packageName: entries.length === 1 ? entries[0].name : "Verlaufsauswahl" + }); + showToast(result.addedLinks > 0 ? `${result.addedLinks} Link(s) zur Queue hinzugefügt` : "Keine Links hinzugefügt"); + } catch { + showToast("Fehler beim Hinzufügen"); + } + }, [showToast]); + + const removeHistoryEntries = useCallback(async (entryIds: string[]): Promise => { + const requested = new Set(entryIds); + const ids = historyEntriesRef.current.filter((entry) => requested.has(entry.id)).map((entry) => entry.id); + if (ids.length === 0) { + return; + } + const confirmed = await askConfirmPrompt({ + title: ids.length === 1 ? "Verlaufseintrag entfernen" : "Verlaufseinträge entfernen", + message: ids.length === 1 ? "Diesen Eintrag aus dem Verlauf entfernen?" : `${ids.length} Einträge aus dem Verlauf entfernen?`, + confirmLabel: "Entfernen", + danger: true + }); + if (!confirmed) { + return; + } + const results = await Promise.allSettled(ids.map((id) => window.rd.removeHistoryEntry(id))); + if (results.some((result) => result.status === "rejected")) { + showToast("Einige Verlaufseinträge konnten nicht entfernt werden"); + await loadHistoryEntries(); + return; + } + const removed = new Set(ids); + applyHistoryEntries(historyEntriesRef.current.filter((entry) => !removed.has(entry.id))); + showToast(ids.length === 1 ? "Verlaufseintrag entfernt" : `${ids.length} Verlaufseinträge entfernt`); + }, [applyHistoryEntries, askConfirmPrompt, loadHistoryEntries, showToast]); + + const clearHistoryEntries = useCallback(async (): Promise => { + if (historyEntriesRef.current.length === 0) { + return; + } + const confirmed = await askConfirmPrompt({ + title: "Verlauf leeren", + message: "Wirklich alle Einträge aus dem Verlauf entfernen?", + confirmLabel: "Verlauf leeren", + danger: true + }); + if (!confirmed) { + return; + } + try { + await window.rd.clearHistory(); + applyHistoryEntries([]); + showToast("Verlauf geleert"); + } catch { + showToast("Verlauf konnte nicht geleert werden"); + await loadHistoryEntries(); + } + }, [applyHistoryEntries, askConfirmPrompt, loadHistoryEntries, showToast]); + + const revealHistoryEntry = useCallback(async (entryId: string): Promise => { + try { + const result = await window.rd.revealHistoryEntry(entryId); + if (result.ok) { + showToast("Zielordner geöffnet"); + return; + } + const messages = { + "entry-not-found": "Verlaufseintrag wurde nicht gefunden", + "invalid-output-dir": "Der gespeicherte Zielordner ist ungültig", + "output-dir-missing": "Der gespeicherte Zielordner existiert nicht mehr", + "output-dir-not-directory": "Das gespeicherte Ziel ist kein Ordner", + "open-failed": "Zielordner konnte nicht geöffnet werden" + } as const; + showToast(messages[result.reason]); + } catch { + showToast("Zielordner konnte nicht geöffnet werden"); + } + }, [showToast]); + + const historyActions = useMemo(() => ({ + onFilterChange: setHistoryFilter, + onQueryChange: setHistoryQuery, + onToggleSelection: (entryId) => { + setSelectedHistoryIds((current) => { + const next = new Set(current); + if (next.has(entryId)) { + next.delete(entryId); + } else { + next.add(entryId); + } + return next; + }); + }, + onToggleSelectAll: (visibleIds) => { + setSelectedHistoryIds((current) => { + const allSelected = visibleIds.length > 0 && visibleIds.every((id) => current.has(id)); + return allSelected ? new Set() : selectVisibleHistoryIds(visibleIds); + }); + }, + onToggleExpansion: (entryId) => { + setHistoryExpandedIds((current) => { + const next = new Set(current); + if (next.has(entryId)) { + next.delete(entryId); + } else { + next.add(entryId); + } + return next; + }); + }, + onRestore: (entryIds) => { void restoreHistoryEntries(entryIds); }, + onReveal: (entryId) => { void revealHistoryEntry(entryId); }, + onRemove: (entryIds) => { void removeHistoryEntries(entryIds); }, + onClearSelection: () => setSelectedHistoryIds(new Set()), + onClearHistory: () => { void clearHistoryEntries(); }, + onContextMenu: (entryId, x, y) => { + setSelectedHistoryIds((current) => { + const visibleSelection = pruneHistoryIds(current, historyVisibleIdsRef.current); + return visibleSelection.has(entryId) ? visibleSelection : new Set([entryId]); + }); + setHistoryCtxMenu({ entryId, x, y }); + } + }), [clearHistoryEntries, removeHistoryEntries, restoreHistoryEntries, revealHistoryEntry]); + + const onStartDownloads = async (): Promise => { await performQuickAction(async () => { if (totalConfiguredAccounts === 0) { setTab("settings"); @@ -3456,8 +3593,9 @@ export function App(): ReactElement { } }; - const onAddLinks = async (): Promise => { - await performQuickAction(async () => { + const onAddLinks = async (): Promise => { + setCollectorError(""); + await performQuickAction(async () => { const activeId = activeCollectorTabRef.current; const active = collectorTabsRef.current.find((t) => t.id === activeId) ?? collectorTabsRef.current[0]; const rawText = active?.text ?? ""; @@ -3466,18 +3604,21 @@ export function App(): ReactElement { const result = await window.rd.addLinks({ rawText, packageName: persisted.packageName }); if (result.addedLinks > 0) { showToast(`${result.addedPackages} Paket(e), ${result.addedLinks} Link(s) hinzugefügt`); - setCollectorTabs((prev) => prev.map((t) => t.id === activeId ? { ...t, text: "" } : t)); + setCollectorTabs((prev) => planCollectorTextReplacement(prev, activeId, "").tabs); + setSelectedCollectorRowIds(new Set()); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } } else { showToast("Keine gültigen Links gefunden"); } - }, (error) => { - showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); - }); - }; - - const onImportDlc = async (): Promise => { - await performQuickAction(async () => { + }, (error) => { + setCollectorError(`Fehler beim Hinzufügen: ${String(error)}`); + showToast(`Fehler beim Hinzufügen: ${String(error)}`, 2600); + }); + }; + + const onImportDlc = async (): Promise => { + setCollectorError(""); + await performQuickAction(async () => { const files = await window.rd.pickContainers(); if (files.length === 0) { return; } await persistDraftSettings(); @@ -3486,12 +3627,14 @@ export function App(): ReactElement { if (result.addedLinks > 0) { showToast(`DLC importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); - } - }, (error) => { - showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); - }); + } else { + setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden"); + showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + } + }, (error) => { + setCollectorError(`Fehler beim DLC-Import: ${String(error)}`); + showToast(`Fehler beim DLC-Import: ${String(error)}`, 2600); + }); }; const onExportPackageSelection = async (packageIds: string[]): Promise => { @@ -3532,22 +3675,26 @@ export function App(): ReactElement { const dlc = files.filter((f) => f.name.toLowerCase().endsWith(".dlc")).map((f) => (f as unknown as { path?: string }).path).filter((v): v is string => !!v); const importFiles = files.filter((f) => /\.(json|txt)$/i.test(f.name)); const droppedText = event.dataTransfer.getData("text/plain") || event.dataTransfer.getData("text/uri-list") || ""; - if (dlc.length > 0) { - await performQuickAction(async () => { + if (dlc.length > 0) { + setCollectorError(""); + await performQuickAction(async () => { await persistDraftSettings(); const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); const result = await window.rd.addContainers(dlc); if (result.addedLinks > 0) { showToast(`Drag-and-Drop: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); - } - }, (error) => { - showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); - }); - } else if (importFiles.length > 0) { - await performQuickAction(async () => { + } else { + setCollectorError("Keine gültigen Links in den DLC-Dateien gefunden"); + showToast("Keine gültigen Links in den DLC-Dateien gefunden", 3000); + } + }, (error) => { + setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`); + showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); + }); + } else if (importFiles.length > 0) { + setCollectorError(""); + await performQuickAction(async () => { await persistDraftSettings(); const existingIds = new Set(Object.keys(snapshotRef.current.session.packages)); let addedPackages = 0; @@ -3561,12 +3708,14 @@ export function App(): ReactElement { if (addedLinks > 0) { showToast(`Importiert: ${addedPackages} Paket(e), ${addedLinks} Link(s)`); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000); - } - }, (error) => { - showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); - }); + } else { + setCollectorError("Keine gültigen Links in den Import-Dateien gefunden"); + showToast("Keine gültigen Links in den Import-Dateien gefunden", 3000); + } + }, (error) => { + setCollectorError(`Fehler bei Drag-and-Drop: ${String(error)}`); + showToast(`Fehler bei Drag-and-Drop: ${String(error)}`, 2600); + }); } else if (droppedText.trim()) { const activeCollectorId = activeCollectorTabRef.current; setCollectorTabs((prev) => prev.map((t) => t.id === activeCollectorId @@ -3587,12 +3736,13 @@ export function App(): ReactElement { }); }; - const onImportQueue = async (): Promise => { - if (actionBusyRef.current) { - return; - } - - actionBusyRef.current = true; + const onImportQueue = async (): Promise => { + if (actionBusyRef.current) { + return; + } + + setCollectorError(""); + actionBusyRef.current = true; setActionBusy(true); const input = document.createElement("input"); @@ -3627,12 +3777,14 @@ export function App(): ReactElement { if (result.addedLinks > 0) { showToast(`Importiert: ${result.addedPackages} Paket(e), ${result.addedLinks} Link(s)`); if (snapshotRef.current.settings.collapseNewPackages) { await collapseNewPackages(existingIds); } - } else { - showToast("Keine gültigen Links in der Datei gefunden", 3000); - } - }, (error) => { - showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); - }); + } else { + setCollectorError("Keine gültigen Links in der Datei gefunden"); + showToast("Keine gültigen Links in der Datei gefunden", 3000); + } + }, (error) => { + setCollectorError(`Import fehlgeschlagen: ${String(error)}`); + showToast(`Import fehlgeschlagen: ${String(error)}`, 2600); + }); }; clearImportQueueFocusListener(); @@ -3641,33 +3793,37 @@ export function App(): ReactElement { input.click(); }; - const setBool = (key: keyof AppSettings, value: boolean): void => { + const setBool = (key: keyof AppSettings, value: boolean): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setText = (key: keyof AppSettings, value: string): void => { + const setText = (key: keyof AppSettings, value: string): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setNum = (key: keyof AppSettings, value: number): void => { + const setNum = (key: keyof AppSettings, value: number): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, [key]: value })); }; - const setSpeedLimitMbps = (value: number): void => { + const setSpeedLimitMbps = (value: number): void => { const mbps = Number.isFinite(value) ? Math.max(0, value) : 0; settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) })); }; @@ -3760,29 +3916,98 @@ export function App(): ReactElement { }); }, [showToast]); - const addCollectorTab = (): void => { - const id = `tab-${nextCollectorId++}`; + const addCollectorTab = (): void => { + const id = `tab-${nextCollectorId++}`; setCollectorTabs((prev) => { const name = `Tab ${prev.length + 1}`; return [...prev, { id, name, text: "" }]; - }); - setActiveCollectorTab(id); - }; + }); + setActiveCollectorTab(id); + setSelectedCollectorRowIds(new Set()); + setCollectorError(""); + }; - const removeCollectorTab = (id: string): void => { - let fallbackId = ""; - setCollectorTabs((prev) => { - if (prev.length <= 1) return prev; - const index = prev.findIndex((tabEntry) => tabEntry.id === id); - if (index < 0) return prev; - const next = prev.filter((tabEntry) => tabEntry.id !== id); - if (activeCollectorTabRef.current === id) { - fallbackId = next[Math.max(0, index - 1)]?.id ?? next[0]?.id ?? ""; - } - return next; - }); - if (fallbackId) setActiveCollectorTab(fallbackId); - }; + const removeCollectorTab = (id: string): void => { + const removal = planCollectorTabRemoval( + collectorTabsRef.current, + activeCollectorTabRef.current, + id + ); + if (removal.tabs === collectorTabsRef.current) { + return; + } + collectorTabsRef.current = removal.tabs; + activeCollectorTabRef.current = removal.activeTabId; + setCollectorTabs(removal.tabs); + setActiveCollectorTab(removal.activeTabId); + setSelectedCollectorRowIds(new Set()); + setCollectorError(""); + }; + + const openCollectorInput = (): void => { + const activeId = activeCollectorTabRef.current; + const active = collectorTabsRef.current.find((entry) => entry.id === activeId) ?? collectorTabsRef.current[0]; + if (!active) { + return; + } + setCollectorError(""); + setCollectorInput({ + tabId: active.id, + tabName: active.name, + baseText: active.text, + draft: active.text + }); + }; + + const commitCollectorInput = (): void => { + if (!collectorInput) { + return; + } + const input = collectorInput; + setCollectorTabs((prev) => { + const currentText = prev.find((entry) => entry.id === input.tabId)?.text ?? input.baseText; + const text = mergeCollectorDraftText(input.baseText, currentText, input.draft); + return planCollectorTextReplacement(prev, input.tabId, text).tabs; + }); + setSelectedCollectorRowIds(new Set()); + setCollectorInput(null); + setCollectorError(""); + }; + + const toggleCollectorRowSelection = (rowId: string): void => { + setSelectedCollectorRowIds((prev) => { + const next = new Set(prev); + if (next.has(rowId)) { + next.delete(rowId); + } else { + next.add(rowId); + } + return next; + }); + }; + + const removeSelectedCollectorRows = (): void => { + if (selectedCollectorRowIds.size === 0) { + return; + } + const activeId = activeCollectorTabRef.current; + const indexes = new Set(); + for (const rowId of selectedCollectorRowIds) { + const separator = rowId.lastIndexOf(":"); + if (separator <= 0 || rowId.slice(0, separator) !== activeId) { + continue; + } + const index = Number(rowId.slice(separator + 1)); + if (Number.isInteger(index) && index >= 0) { + indexes.add(index); + } + } + setCollectorTabs((prev) => prev.map((entry) => entry.id === activeId + ? { ...entry, text: entry.text.split(/\r?\n/).filter((_line, index) => !indexes.has(index)).join("\n") } + : entry)); + setSelectedCollectorRowIds(new Set()); + setCollectorError(""); + }; const onPackageDragStart = useCallback((packageId: string) => { draggedPackageIdRef.current = packageId; @@ -3871,9 +4096,9 @@ export function App(): ReactElement { movePackage(packageId, "down"); }, [movePackage]); - const moveSelectedPackages = useCallback((direction: "up" | "down") => { - const currentOrder = packageOrderRef.current; - const selPkgs = new Set([...selectedIds].filter((id) => snapshot.session.packages[id])); + const moveSelectedPackages = useCallback((direction: "up" | "down", ids: Iterable = selectedIds) => { + const currentOrder = packageOrderRef.current; + const selPkgs = new Set([...ids].filter((id) => snapshot.session.packages[id])); if (selPkgs.size === 0) return; const order = [...currentOrder]; if (direction === "up") { @@ -4048,25 +4273,13 @@ export function App(): ReactElement { const speedHistoryRef = useRef<{ time: number; speed: number }[]>([]); const speedSparklineStateRef = useRef({ history: [], display: 0 }); - const dragSelectRef = useRef(false); - const dragAnchorRef = useRef(null); - const dragDidMoveRef = useRef(false); - const lastClickedIdRef = useRef(null); - - const visibleOrderIds = useMemo(() => { - const ids: string[] = []; - for (const pkg of visiblePackages) { - ids.push(pkg.id); - if (!(collapsedPackages[pkg.id] ?? false)) { - const items = itemsByPackage.get(pkg.id) ?? []; - for (const item of items) { - if (snapshot.settings.hideExtractedItems && item.fullStatus?.startsWith("Entpackt")) continue; - ids.push(item.id); - } - } - } - return ids; - }, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]); + const dragSelectRef = useRef(false); + const dragAnchorRef = useRef(null); + const dragDidMoveRef = useRef(false); + const lastClickedIdRef = useRef(null); + const dragMouseUpRef = useRef<(() => void) | null>(null); + + const visibleOrderIds = downloadsViewCore.visibleRowIds; // Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard // handler can select exactly what the user sees — not the whole unfiltered map. @@ -4102,20 +4315,34 @@ export function App(): ReactElement { }); }, [visibleOrderIds]); - const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => { - if (!e.ctrlKey || e.button !== 0) return; - e.preventDefault(); - dragSelectRef.current = true; - dragAnchorRef.current = id; - dragDidMoveRef.current = false; - const onUp = (): void => { - dragSelectRef.current = false; - dragAnchorRef.current = null; - dragDidMoveRef.current = false; - window.removeEventListener("mouseup", onUp); - }; - window.addEventListener("mouseup", onUp); - }, []); + const onSelectMouseDown = useCallback((id: string, e: React.MouseEvent): void => { + if (!e.ctrlKey || e.button !== 0) return; + e.preventDefault(); + if (dragMouseUpRef.current) { + window.removeEventListener("mouseup", dragMouseUpRef.current); + } + dragSelectRef.current = true; + dragAnchorRef.current = id; + dragDidMoveRef.current = false; + const onUp = (): void => { + dragSelectRef.current = false; + dragAnchorRef.current = null; + dragDidMoveRef.current = false; + window.removeEventListener("mouseup", onUp); + if (dragMouseUpRef.current === onUp) { + dragMouseUpRef.current = null; + } + }; + dragMouseUpRef.current = onUp; + window.addEventListener("mouseup", onUp); + }, []); + + useEffect(() => () => { + if (dragMouseUpRef.current) { + window.removeEventListener("mouseup", dragMouseUpRef.current); + dragMouseUpRef.current = null; + } + }, []); const onSelectMouseEnter = useCallback((id: string): void => { if (!dragSelectRef.current) return; @@ -4196,31 +4423,34 @@ export function App(): ReactElement { }); }, [schedules, settingsDirty]); - const addSchedule = (): void => { + const addSchedule = (): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: [...(prev.bandwidthSchedules ?? []), { id: createScheduleId(), startHour: 0, endHour: 8, speedLimitKbps: 0, enabled: true }] })); }; - const removeSchedule = (idx: number): void => { + const removeSchedule = (idx: number): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: (prev.bandwidthSchedules ?? []).filter((_, i) => i !== idx) })); }; - const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { + const updateSchedule = (idx: number, field: keyof BandwidthScheduleEntry, value: number | boolean): void => { settingsDraftRevisionRef.current += 1; panelDirtyRevisionRef.current += 1; - settingsDirtyRef.current = true; - setSettingsDirty(true); + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); setSettingsDraft((prev) => ({ ...prev, bandwidthSchedules: (prev.bandwidthSchedules ?? []).map((s, i) => i === idx ? { ...s, [field]: value } : s) @@ -4236,100 +4466,6 @@ export function App(): ReactElement { setOpenSubmenu(null); }; - useEffect(() => { - if (!contextMenu) { return; } - const close = (): void => setContextMenu(null); - window.addEventListener("click", close); - window.addEventListener("contextmenu", close); - return () => { - window.removeEventListener("click", close); - window.removeEventListener("contextmenu", close); - }; - }, [contextMenu]); - - useEffect(() => { - if (!accountContextMenu) { return; } - const close = (): void => setAccountContextMenu(null); - window.addEventListener("click", close); - window.addEventListener("contextmenu", close); - return () => { - window.removeEventListener("click", close); - window.removeEventListener("contextmenu", close); - }; - }, [accountContextMenu]); - - useLayoutEffect(() => { - if (!contextMenu || !ctxMenuRef.current) return; - const el = ctxMenuRef.current; - const rect = el.getBoundingClientRect(); - if (rect.bottom > window.innerHeight) { - el.style.top = `${Math.max(0, contextMenu.y - rect.height)}px`; - } - if (rect.right > window.innerWidth) { - el.style.left = `${Math.max(0, contextMenu.x - rect.width)}px`; - } - }, [contextMenu]); - - useLayoutEffect(() => { - if (!accountContextMenu || !accountContextMenuRef.current) return; - const el = accountContextMenuRef.current; - const rect = el.getBoundingClientRect(); - if (rect.bottom > window.innerHeight) { - el.style.top = `${Math.max(0, accountContextMenu.y - rect.height)}px`; - } - if (rect.right > window.innerWidth) { - el.style.left = `${Math.max(0, accountContextMenu.x - rect.width)}px`; - } - }, [accountContextMenu]); - - useEffect(() => { - if (!colHeaderCtx) return; - const close = (e: MouseEvent): void => { - if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return; - if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return; - setColHeaderCtx(null); - }; - window.addEventListener("mousedown", close); - return () => { - window.removeEventListener("mousedown", close); - }; - }, [colHeaderCtx]); - - useLayoutEffect(() => { - if (!colHeaderCtx || !colHeaderCtxRef.current) return; - const el = colHeaderCtxRef.current; - const rect = el.getBoundingClientRect(); - if (rect.bottom > window.innerHeight) { - el.style.top = `${Math.max(0, colHeaderCtx.y - rect.height)}px`; - } - if (rect.right > window.innerWidth) { - el.style.left = `${Math.max(0, colHeaderCtx.x - rect.width)}px`; - } - }, [colHeaderCtx]); - - useEffect(() => { - if (!historyCtxMenu) return; - const close = (): void => setHistoryCtxMenu(null); - window.addEventListener("click", close); - window.addEventListener("contextmenu", close); - return () => { - window.removeEventListener("click", close); - window.removeEventListener("contextmenu", close); - }; - }, [historyCtxMenu]); - - useLayoutEffect(() => { - if (!historyCtxMenu || !historyCtxMenuRef.current) return; - const el = historyCtxMenuRef.current; - const rect = el.getBoundingClientRect(); - if (rect.bottom > window.innerHeight) { - el.style.top = `${Math.max(0, historyCtxMenu.y - rect.height)}px`; - } - if (rect.right > window.innerWidth) { - el.style.left = `${Math.max(0, historyCtxMenu.x - rect.width)}px`; - } - }, [historyCtxMenu]); - const executeDeleteSelection = useCallback((ids: Set): void => { const current = snapshotRef.current; const promises: Promise[] = []; @@ -4680,10 +4816,10 @@ export function App(): ReactElement { // the active search / collapse / hide-extracted filters — selecting // the unfiltered package map would let a later delete hit hidden ones. setSelectedIds(new Set(visibleOrderIdsRef.current)); - } else if (tabRef.current === "history") { - e.preventDefault(); - setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id))); - } + } else if (tabRef.current === "history") { + e.preventDefault(); + setSelectedHistoryIds(selectVisibleHistoryIds(historyVisibleIdsRef.current)); + } return; } } @@ -4696,7 +4832,7 @@ export function App(): ReactElement { if (!openMenu) { return; } const handler = (e: MouseEvent): void => { const target = e.target as HTMLElement; - if (!target.closest(".menu-bar")) { + if (!target.closest(".md-application-menu-tree")) { setOpenMenu(null); setOpenSubmenu(null); } @@ -4713,21 +4849,7 @@ export function App(): ReactElement { return map; }, [snapshot.packageSpeedBps]); - const itemStatusCounts = useMemo(() => { - const counts = { downloading: 0, queued: 0, failed: 0 }; - for (const item of Object.values(snapshot.session.items)) { - if (item.status === "downloading") { - counts.downloading += 1; - } else if (item.status === "queued" || item.status === "reconnect_wait") { - counts.queued += 1; - } else if (item.status === "failed") { - counts.failed += 1; - } - } - return counts; - }, [snapshot.session.items]); - - const providerStats = useMemo(() => { + const providerStats = useMemo(() => { const stats: Record = {}; for (const item of Object.values(snapshot.session.items)) { const hoster = extractHoster(item.url) || "unknown"; @@ -4739,64 +4861,685 @@ export function App(): ReactElement { if (item.status === "failed") stats[hoster].failed += 1; stats[hoster].bytes += item.downloadedBytes; } - return Object.entries(stats); - }, [snapshot.session.items]); - - const runtimeOffsetMs = snapshot.stats.runtimeMeasuredAt > 0 - ? Math.max(0, runtimeNow - snapshot.stats.runtimeMeasuredAt) - : 0; - const liveSessionRuntimeMs = Math.max(0, (snapshot.stats.sessionRuntimeMs || 0) + runtimeOffsetMs); - const liveTotalRuntimeMs = Math.max(0, (snapshot.stats.totalRuntimeMs || 0) + runtimeOffsetMs); - const etaSeparatorIndex = snapshot.etaText.includes(": ") ? snapshot.etaText.indexOf(": ") : -1; - const etaValue = etaSeparatorIndex >= 0 - ? snapshot.etaText.slice(etaSeparatorIndex + 2) - : "--"; - const resetFailedDownloads = (): void => { - if (itemStatusCounts.failed === 0) return; - const failedIds = Object.values(snapshot.session.items) - .filter((it) => it.status === "failed") - .map((it) => it.id); - void window.rd.resetItems(failedIds).catch(() => {}); - }; - const statsSections: StatsSection[] = [ - { - key: "live", - title: "Aktuell", - items: [ - { key: "speed", eyebrow: "Live", label: "Geschwindigkeit", value: snapshot.speedText.replace("Geschwindigkeit: ", "") }, - { key: "eta", eyebrow: "Live", label: "Restzeit", value: etaValue, compactValue: true }, - { key: "active", eyebrow: "Queue", label: "Aktive Downloads", value: String(itemStatusCounts.downloading) }, - { key: "queued", eyebrow: "Queue", label: "In Warteschlange", value: String(itemStatusCounts.queued) }, - { - key: "failed", - eyebrow: "Status", - label: "Fehlerhaft", - value: String(itemStatusCounts.failed), - danger: itemStatusCounts.failed > 0, - clickable: itemStatusCounts.failed > 0, - title: itemStatusCounts.failed > 0 ? "Klicken zum Zurücksetzen aller fehlerhaften Downloads" : undefined, - onClick: resetFailedDownloads - }, - { key: "packages", eyebrow: "Queue", label: "Pakete", value: String(snapshot.stats.totalPackages) } - ] - }, - { - key: "summary", - title: "Bilanz", - items: [ - { key: "downloaded-session", eyebrow: "Session", label: "Heruntergeladen", value: humanSize(snapshot.stats.totalDownloaded) }, - { key: "downloaded-total", eyebrow: "Gesamt", label: "Heruntergeladen", value: humanSize(snapshot.stats.totalDownloadedAllTime) }, - { key: "runtime-session", eyebrow: "Session", label: "Laufzeit", value: formatRuntimeDuration(liveSessionRuntimeMs), compactValue: true }, - { key: "runtime-total", eyebrow: "Gesamt", label: "Laufzeit", value: formatRuntimeDuration(liveTotalRuntimeMs), compactValue: true }, - { key: "files-session", eyebrow: "Session", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesSession) }, - { key: "files-total", eyebrow: "Gesamt", label: "Fertige Dateien", value: String(snapshot.stats.totalFilesAllTime) } - ] - } - ]; - - return ( -
{ + const nextDescending = downloadsSortColumn === column ? !downloadsSortDescending : false; + setDownloadsSortColumn(column); + setDownloadsSortDescending(nextDescending); + const baseOrder = packageOrderRef.current.length > 0 ? packageOrderRef.current : snapshot.session.packageOrder; + const sorted = column === "progress" + ? sortPackageOrderByProgress(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) + : column === "size" + ? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) + : column === "hoster" + ? sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) + : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); + pendingPackageOrderRef.current = [...sorted]; + pendingPackageOrderAtRef.current = Date.now(); + packageOrderRef.current = sorted; + setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: [...sorted] } })); + void window.rd.reorderPackages(sorted).catch((error) => { + pendingPackageOrderRef.current = null; + pendingPackageOrderAtRef.current = 0; + packageOrderRef.current = serverPackageOrderRef.current; + setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } })); + showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); + }); + }, [downloadsSortColumn, downloadsSortDescending, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); + + const clearDownloadQueue = useCallback((): void => { + void performQuickAction(async () => { + const confirmed = await askConfirmPrompt({ title: "Queue löschen", message: "Wirklich alle Einträge aus der Queue löschen?", confirmLabel: "Alles löschen", danger: true }); + if (confirmed) await window.rd.clearAll(); + }); + }, [askConfirmPrompt, performQuickAction]); + + const activateDownloadSchedule = useCallback((): void => { + if (!scheduleTimeInput) return; + const [hours, minutes] = scheduleTimeInput.split(":").map(Number); + const now = new Date(); + const target = new Date(now); + target.setHours(hours, minutes, 0, 0); + if (target.getTime() <= now.getTime()) target.setDate(target.getDate() + 1); + void window.rd.updateSettings({ scheduledStartEpochMs: target.getTime() }).catch(() => {}); + setSchedulePickerOpen(false); + }, [scheduleTimeInput]); + + const removeActionableDownloads = useCallback((): void => { + const ids = new Set(downloadsViewCore.actionableSelectedIds); + if (ids.size === 0) return; + if (settingsDraft.confirmDeleteSelection) { + setDeleteConfirm({ ids, dontAsk: false }); + } else { + executeDeleteSelection(ids); + } + }, [downloadsViewCore.actionableSelectedIds, executeDeleteSelection, settingsDraft.confirmDeleteSelection]); + + const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]); + const downloadsViewModel = useMemo(() => ({ + ...downloadsViewCore, + running: snapshot.session.running, + paused: snapshot.session.paused, + canStart: snapshot.canStart, + canPause: snapshot.canPause, + canStop: snapshot.canStop, + actionBusy, + reconnectSeconds: snapshot.reconnectSeconds, + reconnectReason: snapshot.session.reconnectReason, + clipboardWatcher: snapshot.clipboardActive, + scheduleActive: snapshot.settings.scheduledStartEpochMs > 0, + scheduleOpen: schedulePickerOpen, + scheduleTime: scheduleTimeInput, + scheduleLabel: scheduleCountdown || (snapshot.settings.scheduledStartEpochMs > 0 ? new Date(snapshot.settings.scheduledStartEpochMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""), + packageSpeedBps: downloadPackageSpeeds, + editingPackageId, + editingName, + columnOrder, + gridTemplate, + sortColumn: downloadsSortColumn, + sortDirection: downloadsSortDescending ? "desc" : "asc", + status: { + packages: snapshot.stats.totalPackages, + links: Object.keys(snapshot.session.items).length, + session: humanSize(snapshot.stats.totalDownloaded), + total: humanSize(snapshot.stats.totalDownloadedAllTime), + 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]); + + const downloadsActions: DownloadsViewActions = { + onDisplayModeChange: setDownloadDisplayMode, + onFilterChange: setDownloadFilter, + onProviderFilterChange: setDownloadProviderFilter, + onQueryChange: setDownloadSearch, + onAddLinks: () => { + setTab("collector"); + openCollectorInput(); + }, + onStartDownloads: () => { + if (snapshot.session.paused) { + setSnapshot((current) => ({ ...current, session: { ...current.session, paused: false } })); + void window.rd.togglePause().catch(() => {}); + } else { + void onStartDownloads(); + } + }, + onPauseDownloads: () => { + setSnapshot((current) => ({ ...current, session: { ...current.session, paused: true } })); + void window.rd.togglePause().catch(() => {}); + }, + onStopDownloads: () => { void performQuickAction(() => window.rd.stop()); }, + onToggleSchedule: () => { + setSchedulePickerOpen((current) => !current); + setScheduleTimeInput(""); + }, + onScheduleTimeChange: setScheduleTimeInput, + onActivateSchedule: activateDownloadSchedule, + onCancelSchedule: () => { void window.rd.updateSettings({ scheduledStartEpochMs: 0 }).catch(() => {}); }, + onMoveSelectionUp: () => moveSelectedPackages("up", downloadsViewCore.actionableSelectedIds), + onMoveSelectionDown: () => moveSelectedPackages("down", downloadsViewCore.actionableSelectedIds), + onRenameSelection: () => { + const packageId = downloadsViewCore.actionableSelectedPackageIds[0]; + const entry = packageId ? snapshot.session.packages[packageId] : null; + if (entry) onPackageStartEdit(entry.id, entry.name); + }, + onRemoveSelection: removeActionableDownloads, + onToggleClipboardWatcher: () => { void performQuickAction(() => window.rd.toggleClipboard()); }, + onClearAll: clearDownloadQueue, + onToggleAllPackages: () => { + const targetState = !allPackagesCollapsed; + setCollapsedPackages((current) => { + const next = { ...current }; + for (const entry of packages) { + next[entry.id] = targetState; + if (targetState) manualCollapsedPkgsRef.current.add(entry.id); + else { + manualCollapsedPkgsRef.current.delete(entry.id); + autoExpandedPkgsRef.current.delete(entry.id); + } + } + return next; + }); + }, + onShowAllPackages: () => setShowAllPackages(true), + onPackageDragStart, + onPackageDrop, + onPackageDragEnd, + onSetVisibleSelection: (ids, selected) => { + setSelectedIds((current) => { + const next = new Set(current); + for (const id of ids) { + if (selected) next.add(id); + else next.delete(id); + } + return next; + }); + }, + onToggleSelection: onSelectId, + onSelectionMouseDown: onSelectMouseDown, + onSelectionMouseEnter: onSelectMouseEnter, + onTogglePackage: onPackageToggle, + onTogglePackageCollapse: onPackageToggleCollapse, + onStartPackageRename: onPackageStartEdit, + onPackageRenameChange: setEditingName, + onCommitPackageRename: (packageId, value) => { + const currentName = snapshot.session.packages[packageId]?.name ?? ""; + onPackageFinishEdit(packageId, currentName, value); + }, + onCancelPackageRename: () => { + setEditingPackageId(null); + setEditingName(""); + }, + onCancelPackage: onPackageCancel, + onMovePackageUp: onPackageMoveUp, + onMovePackageDown: onPackageMoveDown, + onRemoveItem: onPackageRemoveItem, + onOpenContextMenu: (id, x, y, packageId) => { + 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); + }, + onColumnDragOver: (column, event) => { + if (dragColId && dragColId !== column) { + event.preventDefault(); + setDropTargetCol(column); + } + }, + 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(() => {}); + }, + onColumnDragEnd: () => { + setDragColId(null); + setDropTargetCol(null); + }, + onColumnContextMenu: (_column, x, y) => setColHeaderCtx({ x, y }) + }; + + const statisticsActions: StatisticsViewActions = { + onRangeChange: setStatisticsRange, + onResetSession: () => { + void window.rd.resetSessionStats().then(() => { + showToast("Session-Statistik zurückgesetzt", 1800); + }).catch((error) => { + showToast(`Session-Reset fehlgeschlagen: ${String(error)}`, 2400); + }); + }, + onResetAll: () => { + void window.rd.resetDownloadStats().then(() => { + showToast("Gesamt-Downloadstatistik zurückgesetzt", 1800); + }).catch((error) => { + showToast(`Download-Reset fehlgeschlagen: ${String(error)}`, 2400); + }); + }, + onResetErrors: () => { + const failedIds = Object.values(snapshot.session.items) + .filter((item) => item.status === "failed") + .map((item) => item.id); + if (failedIds.length === 0) { + return; + } + void window.rd.resetItems(failedIds).catch(() => {}); + } + }; + const collectorActions: CollectorViewActions = { + onTabSelect: (tabId) => { + activeCollectorTabRef.current = tabId; + setActiveCollectorTab(tabId); + setSelectedCollectorRowIds(new Set()); + setCollectorError(""); + }, + onTabAdd: addCollectorTab, + onTabRemove: removeCollectorTab, + onOpenInput: openCollectorInput, + onImportDlc: () => { void onImportDlc(); }, + onImportFile: () => { void onImportQueue(); }, + onExportQueue: () => { void onExportQueue(); }, + onSubmit: () => { void onAddLinks(); }, + onQueryChange: setCollectorQuery, + onSelectionChange: toggleCollectorRowSelection, + onRemoveSelected: removeSelectedCollectorRows + }; + + const settingsFormModel = useMemo(() => buildSettingsFormViewModel({ + settings: settingsDraft, + section: settingsSubTab, + speedLimitInput, + scheduleSpeedInputs, + themeChoice: settingsThemeChoice + }), [scheduleSpeedInputs, settingsDraft, settingsSubTab, settingsThemeChoice, speedLimitInput]); + + const accountRowViewId = (row: AccountTableRow): string => buildAccountRowId( + row.entry.service, + row.modeLabel, + row.accountId || row.rowKey + ); + const accountRowBindings = useMemo(() => new Map(accountRows.map((row) => [accountRowViewId(row), row])), [accountRows]); + const activeAccountContextRow = accountContextMenu + ? accountRowBindings.get(accountContextMenu.rowId) ?? null + : null; + const accountSources = useMemo(() => accountRows.map((row) => { + const checkedStatus = row.accountId ? snapshot.settings.debridAccountStatuses?.[row.accountId] : undefined; + const checking = Boolean(row.accountId && megaCheckingIds.has(row.accountId)); + const state: AccountRowSource["status"]["state"] = row.disabled + ? "disabled" + : checking + ? "checking" + : !checkedStatus + ? "unchecked" + : checkedStatus && !checkedStatus.valid + ? "invalid" + : checkedStatus && !checkedStatus.isPremium + ? "free" + : "premium"; + return { + identityId: row.accountId || row.rowKey, + service: row.entry.service, + hoster: row.hosterLabel, + mode: row.modeLabel, + icon: ACCOUNT_SERVICE_ICONS[row.entry.service], + enabled: !row.disabled, + status: { + state, + message: checkedStatus?.message || row.entry.statusLabel, + premiumUntilMs: checkedStatus?.premiumUntilMs ?? null, + email: checkedStatus?.email + }, + dailyLimitBytes: row.dailyLimitBytes, + dailyUsageBytes: row.dailyUsedBytes, + username: row.username, + credentialKind: row.credentialLabel.includes("API") ? "api-key" : row.credentialLabel.includes("•") ? "password" : "protected", + canCheck: row.checkable + }; + }), [accountRows, megaCheckingIds, snapshot.settings.debridAccountStatuses]); + const selectedAccountViewId = useMemo(() => { + const selectedRow = selectedAccountRowKey ? accountRows.find((row) => row.rowKey === selectedAccountRowKey) : null; + return selectedRow ? accountRowViewId(selectedRow) : null; + }, [accountRows, selectedAccountRowKey]); + const projectedAccountRows = useMemo(() => projectAccountRows( + accountSources, + selectedAccountViewId ? [selectedAccountViewId] : [], + runtimeNow + ), [accountSources, runtimeNow, selectedAccountViewId]); + const visibleAccountRows = useMemo(() => accountStatusSort === "none" + ? projectedAccountRows + : sortAccountRows(projectedAccountRows, accountStatusSort), [accountStatusSort, projectedAccountRows]); + const routingEntries = useMemo(() => Object.entries(settingsDraft.hosterRouting || {}).sort(([left], [right]) => left.localeCompare(right)), [settingsDraft.hosterRouting]); + const usedRoutingHosters = useMemo(() => new Set(routingEntries.map(([hosterId]) => hosterId)), [routingEntries]); + const routingProviderOptions = useMemo(() => configuredProviders.map((provider) => ({ + value: provider, + label: providerLabelWithMode(provider, settingsDraft) + })), [configuredProviders, settingsDraft]); + const accountWorkspaceModel: AccountWorkspaceViewModel = { + activePanel: accountManagementTab, + rows: visibleAccountRows, + selectedIds: selectedAccountViewId ? [selectedAccountViewId] : [], + busy: actionBusy || accountCheckBusy, + allEnabled: accountRows.length > 0 && accountRows.some((row) => !row.disabled), + statusSort: accountStatusSort, + rules: { + providerOrder: activeProviderOrder.map((provider) => providerLabelWithMode(provider, settingsDraft)), + routing: routingEntries.map(([hosterId, provider]) => `${KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId} → ${providerLabelWithMode(provider, settingsDraft)}`), + autoFallback: settingsDraft.autoProviderFallback, + rememberCredentials: settingsDraft.rememberToken, + rotationEvents: (snapshot.rotationEvents || []).map((event) => ({ + id: event.id, + title: `${event.provider} · ${event.accountLabel}`, + detail: `${new Date(event.at).toLocaleTimeString()} · ${rotationEventText(event)}${event.reason ? ` (${event.reason})` : ""}` + })), + routingEntries: routingEntries.map(([hosterId, provider]) => ({ + hosterId, + hosterLabel: KNOWN_HOSTERS.find((hoster) => hoster.id === hosterId)?.label || hosterId, + provider, + providers: routingProviderOptions + })), + availableRoutingHosters: KNOWN_HOSTERS + .filter((hoster) => !usedRoutingHosters.has(hoster.id)) + .map((hoster) => ({ value: hoster.id, label: hoster.label })) + } + }; + + const setHosterRouting = (hosterRouting: Record): void => { + settingsDraftRevisionRef.current += 1; + panelDirtyRevisionRef.current += 1; + settingsDirtyRef.current = true; + setSettingsDirty(true); + setSettingsSaveState("dirty"); + setSettingsDraft((current) => ({ ...current, hosterRouting })); + }; + const accountWorkspaceActions: AccountWorkspaceActions = { + onPanelChange: setAccountManagementTab, + onSelect: (rowId) => setSelectedAccountRowKey(accountRowBindings.get(rowId)?.rowKey ?? null), + onToggleEnabled: (rowId) => { + const row = accountRowBindings.get(rowId); + if (row) toggleAccountTableRow(row); + }, + onEdit: (rowId) => { + const row = accountRowBindings.get(rowId); + if (row) openEditAccountDialog(row); + }, + onContextMenu: (rowId, x, y) => { + const row = accountRowBindings.get(rowId); + if (row) setAccountContextMenu({ x, y, rowId }); + }, + onAdd: openCreateAccountDialog, + onRemoveSelected: () => { + const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null; + if (row) removeAccountTableRow(row); + }, + onCheckAll: () => { void checkAllAccounts(); }, + onSetAllEnabled: (enabled) => { void setAllAccountsEnabled(enabled); }, + onStatusSort: cycleAccountStatusSort, + onMoveProvider: (index, direction) => { + const target = index + direction; + if (target < 0 || target >= activeProviderOrder.length) return; + const next = [...activeProviderOrder]; + [next[index], next[target]] = [next[target], next[index]]; + setProviderOrder(next); + }, + onProviderDragStart: (event, index) => { + const provider = activeProviderOrder[index]; + if (provider) onProviderDragStart(event, provider); + }, + onProviderDragOver: (event, index) => { + const provider = activeProviderOrder[index]; + if (provider) onProviderDragOver(event, provider); + }, + onProviderDrop: (event, index) => { + const provider = activeProviderOrder[index]; + if (provider) onProviderDrop(event, provider); + }, + onProviderDragEnd, + onToggleAutoFallback: (enabled) => setBool("autoProviderFallback", enabled), + onToggleRememberCredentials: (enabled) => setBool("rememberToken", enabled), + onRoutingProviderChange: (hosterId, provider) => setHosterRouting({ + ...(settingsDraft.hosterRouting || {}), + [hosterId]: provider as DebridProvider + }), + onRoutingRemove: (hosterId) => { + const next = { ...(settingsDraft.hosterRouting || {}) }; + delete next[hosterId]; + setHosterRouting(next); + }, + onRoutingAdd: (hosterId) => { + const resolvedHosterId = hosterId === "__custom" + ? (window.prompt("Hoster-Domain eingeben:") || "").trim().toLowerCase().replace(/^www\./, "").split(".")[0] + : hosterId; + if (!resolvedHosterId || settingsDraft.hosterRouting?.[resolvedHosterId] || !configuredProviders[0]) return; + setHosterRouting({ ...(settingsDraft.hosterRouting || {}), [resolvedHosterId]: configuredProviders[0] }); + } + }; + + const settingsFormActions: SettingsViewActions["form"] = { + onChange: (fieldId, value) => { + const scheduleMatch = /^schedule:(\d+):(startHour|endHour|enabled|speedLimitMbps)$/.exec(fieldId); + if (scheduleMatch) { + const index = Number(scheduleMatch[1]); + const field = scheduleMatch[2]; + if (field === "speedLimitMbps") { + const schedule = schedules[index]; + if (schedule) { + const key = schedule.id || `schedule-${index}`; + setScheduleSpeedInputs((current) => ({ ...current, [key]: String(value) })); + } + } else if (field === "enabled") { + updateSchedule(index, "enabled", Boolean(value)); + } else { + const parsed = Number(value); + if (Number.isFinite(parsed)) updateSchedule(index, field as "startHour" | "endHour", Math.max(0, Math.min(23, parsed))); + } + return; + } + if (fieldId === "speedLimitInput") { + setSpeedLimitInput(String(value)); + return; + } + if (fieldId === "theme") { + const choice = value as SettingsThemeChoice; + const next = resolveSettingsThemeChoice(choice, window.matchMedia("(prefers-color-scheme: light)").matches); + setSettingsThemeChoice(choice); + setText("theme", next); + applyTheme(next); + return; + } + if (typeof value === "boolean") { + setBool(fieldId as keyof AppSettings, value); + return; + } + const numericLimits: Partial> = { + maxParallel: [1, 50, 1], + retryLimit: [0, 99, 0], + historyMaxEntries: [50, 100000, 500], + historyMaxAgeDays: [0, 3650, 0], + maxParallelExtract: [1, 8, 2], + reconnectWaitSeconds: [10, 600, 45] + }; + const bounds = numericLimits[fieldId as keyof AppSettings]; + if (bounds) { + const parsed = Number(value); + setNum(fieldId as keyof AppSettings, Math.max(bounds[0], Math.min(bounds[1], Number.isFinite(parsed) ? parsed : bounds[2]))); + } else { + setText(fieldId as keyof AppSettings, String(value)); + } + }, + onCommit: (fieldId, value) => { + const scheduleMatch = /^schedule:(\d+):speedLimitMbps$/.exec(fieldId); + if (scheduleMatch) { + const index = Number(scheduleMatch[1]); + const parsed = parseMbpsInput(value); + const schedule = schedules[index]; + if (!schedule) return; + const key = schedule.id || `schedule-${index}`; + if (parsed === null) { + setScheduleSpeedInputs((current) => ({ ...current, [key]: formatMbpsInputFromKbps(schedule.speedLimitKbps) })); + return; + } + const nextKbps = Math.floor(parsed * 1024); + setScheduleSpeedInputs((current) => ({ ...current, [key]: formatMbpsInputFromKbps(nextKbps) })); + updateSchedule(index, "speedLimitKbps", nextKbps); + return; + } + if (fieldId === "speedLimitInput") { + const parsed = parseMbpsInput(value); + if (parsed === null) { + setSpeedLimitInput(formatMbpsInputFromKbps(settingsDraft.speedLimitKbps)); + return; + } + setSpeedLimitMbps(parsed); + setSpeedLimitInput(formatMbpsInputFromKbps(Math.floor(parsed * 1024))); + } + }, + onAction: (fieldId) => { + if (fieldId === "schedule:add") { + addSchedule(); + return; + } + const removeScheduleMatch = /^schedule:(\d+):remove$/.exec(fieldId); + if (removeScheduleMatch) { + removeSchedule(Number(removeScheduleMatch[1])); + return; + } + if (fieldId === "update:check") { + void onCheckUpdates(); + return; + } + if (fieldId === "notifyUrl") { + void performQuickAction(async () => { + const ok = await window.rd.testNotification(settingsDraft.notifyUrl, settingsDraft.notifyMention); + showToast(ok ? "Test-Nachricht gesendet" : "Test fehlgeschlagen", ok ? 2400 : 3600); + }); + return; + } + const targetKey = fieldId === "outputDir" ? "outputDir" : fieldId === "extractDir" ? "extractDir" : fieldId === "mkvLibraryDir" ? "mkvLibraryDir" : null; + if (targetKey) { + void performQuickAction(async () => { + const path = await window.rd.pickFolder(); + if (path) setText(targetKey, path); + }); + } + } + }; + const settingsViewModel: SettingsViewModel = { + section: settingsSubTab, + saveState: settingsDirty && settingsSaveState === "clean" ? "dirty" : settingsSaveState, + form: settingsFormModel, + accounts: accountWorkspaceModel + }; + const settingsViewActions: SettingsViewActions = { + onSectionChange: setSettingsSubTab, + onSave: () => { void onSaveSettings(); }, + form: settingsFormActions, + accounts: accountWorkspaceActions + }; + + const accountAddOptions: AccountAddOption[] = filteredAccountDialogOptions.map((option) => ({ + id: option.kind, + service: option.service, + title: option.serviceLabel, + mode: option.modeLabel, + description: option.pickerDescription, + functionLabel: getAccountPickerFunctionLabel(option), + filter: option.modeLabel === "API" ? "api" : "web", + multi: option.kind === "megadebrid-api" || option.kind === "megadebrid-web" || option.kind === "debridlink-api", + icon: ACCOUNT_SERVICE_ICONS[option.service] + })); + const accountAddFields: AccountDialogField[] = accountDialog && accountDialogOption ? [ + ...((accountDialog.kind === "megadebrid-api" || accountDialog.kind === "megadebrid-web") ? [ + { id: "megaNewLogin", label: "Login / E-Mail", type: "text" as const, value: accountDialog.megaNewLogin }, + { id: "megaNewPassword", label: "Passwort", type: "password" as const, value: accountDialog.megaNewPassword } + ] : accountDialogOption.needsCredentials ? [ + { id: "login", label: "Login / E-Mail", type: "text" as const, value: accountDialog.login }, + { id: "password", label: "Passwort", type: "password" as const, value: accountDialog.password } + ] : []), + ...(accountDialogOption.needsToken ? [ + { id: "token", label: accountDialog.kind === "debridlink-api" ? "API-Key" : "Token / API-Key", type: "password" as const, value: accountDialog.token } + ] : []), + { + id: "dailyLimitGb", + label: "Tageslimit (GB, optional)", + type: "number" as const, + value: accountDialog.dailyLimitGb, + placeholder: "Kein Limit", + help: "Der Zähler wird täglich um 00:00 Uhr zurückgesetzt." + } + ] : []; + const accountAddDialog = ( + updateAccountDialogKind(optionId as AccountKind), + onFieldChange: (fieldId, value) => setAccountDialog((current) => current ? { ...current, [fieldId]: value } : current), + onClose: closeAccountDialog, + onSubmit: () => { + const quickAction = accountDialog?.kind ? getAccountQuickActionMeta(accountDialog.kind)?.action : undefined; + void onSaveAccountDialog(quickAction); + } + }} + model={{ + open: Boolean(accountDialog), + query: accountDialogSearch, + filter: accountDialogModeFilter, + options: accountAddOptions, + selectedOptionId: accountDialog?.kind ?? null, + fields: accountAddFields, + error: "", + busy: actionBusy + }} + /> + ); + const accountEditFields: AccountDialogField[] = accountEditDialog && accountEditOption ? [ + ...((accountEditDialog.target.type === "mega" || accountEditOption.needsCredentials) ? [ + { id: "login", label: "Login / E-Mail", type: "text" as const, value: accountEditDialog.login }, + { id: "password", label: "Passwort", type: "password" as const, value: accountEditDialog.password } + ] : []), + ...((accountEditDialog.target.type === "debridlink" || accountEditOption.needsToken) ? [ + { id: "token", label: accountEditDialog.target.type === "debridlink" ? "API-Key" : "Token / API-Key", type: "password" as const, value: accountEditDialog.token } + ] : []), + { + id: "dailyLimitGb", + label: "Tageslimit (GB, optional)", + type: "number" as const, + value: accountEditDialog.dailyLimitGb, + placeholder: "Kein Limit" + } + ] : []; + const checkAccountEditDialog = (): void => { + if (!accountEditDialog) return; + const validationError = validateAccountEdit(accountEditDialog, settingsDraft); + if (validationError) { + showToast(validationError, 2800); + return; + } + const editSnapshot = accountEditDialog; + void performQuickAction(async () => { + if (editSnapshot.target.type === "mega" || editSnapshot.target.type === "debridlink") { + const checkSettings = buildAccountEditCheckSettings(applyAccountEdit(settingsDraft, editSnapshot), editSnapshot); + const statuses = await window.rd.checkDebridAccounts(checkSettings, true, getAccountEditExpectedStatusId(editSnapshot) || undefined); + const statusError = validateAccountEditStatuses(editSnapshot, statuses); + if (statusError) throw new Error(statusError); + showToast("Account erfolgreich geprüft", 2200); + return; + } + const quickAction = getAccountQuickActionMeta(editSnapshot.target.kind); + if (quickAction) { + await runAccountQuickAction(quickAction.action); + } else { + showToast("Für diesen Dienst ist keine direkte Statusprüfung verfügbar.", 2800); + } + }, (error) => showToast(`Prüfung fehlgeschlagen: ${String(error)}`, 3200)); + }; + const accountEditDialogView = accountEditDialog && accountEditOption ? ( + setAccountEditDialog((current) => current ? { ...current, [fieldId]: value } : current), + onClose: closeAccountEditDialog, + onCheck: checkAccountEditDialog, + onSave: () => { void onSaveAccountEditDialog(); }, + onRemove: () => { + const row = accountEditRow; + closeAccountEditDialog(); + if (row) removeAccountTableRow(row); + }, + onToggleEnabled: () => { + if (accountEditRow) toggleAccountTableRow(accountEditRow); + } + }} + model={{ + open: true, + hoster: accountEditOption.serviceLabel, + mode: accountEditOption.modeLabel, + identity: resolveAccountUsername(accountEditRow?.username || accountEditDialog.login, accountEditStatus?.email), + enabled: !accountEditRow?.disabled, + fields: accountEditFields, + error: "", + busy: actionBusy + }} + /> + ) : null; + + return ( +
{ event.preventDefault(); if (draggedPackageIdRef.current) { return; } @@ -4819,10 +5562,59 @@ export function App(): ReactElement { dragOverRef.current = false; setDragOver(false); } - }} - onDrop={onDrop} - > -
+ ) : tab === "collector" ? ( +
+ 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. +
+ ) : tab === "history" ? ( +
+ Abgeschlossene und gelöschte Pakete bleiben hier durchsuchbar. Details zeigen Zielordner, Provider und gespeicherte Linkadressen. +
+ ) : tab === "statistics" ? ( +
+ {statisticsViewModel.message} Für sieben und 30 Tage bleiben Kennzahlen leer, solange keine historischen Buckets gespeichert werden. +
+ ) : null} + footer={tab === "downloads" ? : tab === "history" ? : null} + headerActions={( + <> +