feat: deliver the redesigned desktop workspace

Rebuild downloads, link collection, settings, history, and statistics around a responsive desktop shell with compact account and queue tables, contextual navigation, persistent update affordances, unified overlays, and accessible keyboard interactions.

Add safe history-folder reveal IPC, responsive 2560/1920/1366/1120 coverage, deterministic visual fixtures, focused component regressions, and release-tree exclusions for internal working files. Bump the public application version to 2.0.13.
This commit is contained in:
Sucukdeluxe
2026-08-10 14:15:57 +02:00
parent d844b33501
commit 069babfd54
77 changed files with 17442 additions and 3333 deletions
+8
View File
@@ -1,4 +1,12 @@
node_modules/ node_modules/
.worktrees/
.claude/
.codex/
.superpowers/
docs/superpowers/
tasks/
AGENTS.md
CLAUDE.md
build/ build/
dist/ dist/
release/ release/
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.12", "version": "2.0.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.12", "version": "2.0.13",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.12", "version": "2.0.13",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
@@ -8,6 +8,7 @@
"scripts": { "scripts": {
"dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"", "dev": "concurrently -k \"npm:dev:main:watch\" \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:renderer": "vite", "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: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 .", "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", "build": "npm run build:main && npm run build:renderer",
+104
View File
@@ -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<HistoryEntry[]>;
stat: (directory: string) => Promise<HistoryRevealStat>;
openPath: (directory: string) => Promise<string>;
}
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<HistoryRevealResult> {
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" };
}
}
+8
View File
@@ -10,6 +10,7 @@ import { sendNotification } from "./notify";
import { APP_NAME } from "./constants"; import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils"; 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 { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
@@ -510,6 +511,13 @@ function registerIpcHandlers(): void {
validateString(entryId, "entryId"); validateString(entryId, "entryId");
return controller.removeHistoryEntry(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 () => { ipcMain.handle(IPC_CHANNELS.EXPORT_QUEUE, async () => {
const options = { const options = {
defaultPath: `rd-queue-export.json`, defaultPath: `rd-queue-export.json`,
+2
View File
@@ -9,6 +9,7 @@ import {
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput, EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
HistoryRevealResult,
PackagePriority, PackagePriority,
RemoteDiagnosticsInfo, RemoteDiagnosticsInfo,
RendererErrorReport, RendererErrorReport,
@@ -95,6 +96,7 @@ const api: ElectronApi = {
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
revealHistoryEntry: (entryId: string): Promise<HistoryRevealResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_HISTORY_ENTRY, entryId),
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
+1655 -2715
View File
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import type { AudioStripSummary, DebridProvider } from "../shared/types";
export const providerLabels: Record<DebridProvider, string> = {
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"
};
export function compactProviderLabels(labels: string[]): string {
const groups = new Map<string, string[]>();
for (const label of [...new Set(labels)]) {
const match = label.match(/^(.+?)\s*\((.+)\)$/);
if (!match) {
groups.set(label, []);
continue;
}
const details = groups.get(match[1]) ?? [];
details.push(match[2]);
groups.set(match[1], details);
}
return [...groups].map(([base, details]) => details.length === 0 ? base : `${base} (${details.join(" + ")})`).join(", ");
}
export function formatDateTime(timestamp: number): string {
if (!timestamp) return "";
const date = new Date(timestamp);
const day = String(date.getDate()).padStart(2, "0");
const month = String(date.getMonth() + 1).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${day}.${month}.${date.getFullYear()} - ${hours}:${minutes}`;
}
export 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 "";
}
}
export 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`);
return {
text: `Tonspur: ${parts.join(" · ") || "—"}`,
tooltip: summary.files.map((entry) => `${entry.name}: ${entry.action} (${entry.reason}${entry.languages ? `, Spuren: ${entry.languages}` : ""})`).join("\n"),
attention: summary.skippedNoGerman > 0 || summary.skippedNoTool > 0 || summary.failed > 0
};
}
export function formatSpeedMbps(speedBps: number): string {
return `${(Math.max(0, speedBps || 0) / (1024 * 1024)).toFixed(2)} MB/s`;
}
export function humanSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(2)} MB`;
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
return `${(bytes / 1024 ** 4).toFixed(3)} TB`;
}
+25 -41
View File
@@ -43,51 +43,35 @@ export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoun
if (!this.state.hasError) { if (!this.state.hasError) {
return this.props.children; return this.props.children;
} }
const overlay: React.CSSProperties = {
position: "fixed",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
background: "#070b14",
color: "#e6edf6",
fontFamily: "Segoe UI, system-ui, sans-serif",
textAlign: "center"
};
const pre: React.CSSProperties = {
maxWidth: 640,
maxHeight: 200,
overflow: "auto",
padding: 12,
background: "#0d1422",
border: "1px solid #243049",
borderRadius: 6,
color: "#ff9a8c",
fontSize: 12,
whiteSpace: "pre-wrap",
textAlign: "left"
};
const button: React.CSSProperties = {
padding: "8px 20px",
background: "#2d5cff",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14
};
return ( return (
<div style={overlay}> <div
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1> className="ui-error-boundary"
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}> role="alert"
aria-live="assertive"
aria-atomic="true"
aria-labelledby="renderer-error-title"
aria-describedby="renderer-error-description renderer-error-details"
>
<div className="ui-error-boundary-content">
<h1 className="ui-error-boundary-title" id="renderer-error-title">
Die Oberfläche hat einen Fehler ausgelöst
</h1>
<p className="ui-error-boundary-description" id="renderer-error-description">
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben. Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p> </p>
<pre style={pre}>{this.state.message}</pre> <pre className="ui-error-boundary-details" id="renderer-error-details" tabIndex={0}>
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button> {this.state.message}
</pre>
<button
autoFocus
className="ui-error-boundary-reload"
type="button"
onClick={this.handleReload}
>
Oberfläche neu laden
</button>
</div>
</div> </div>
); );
} }
+1
View File
@@ -2,6 +2,7 @@ import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { ErrorBoundary } from "./error-boundary"; import { ErrorBoundary } from "./error-boundary";
import "./theme.css";
import "./styles.css"; import "./styles.css";
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise // Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
+38
View File
@@ -0,0 +1,38 @@
import type { ReactElement, ReactNode } from "react";
import { Icon } from "../ui/Icon";
import { buildMainNavigation, type MainView } from "./shell-model";
export interface AppHeaderProps {
activeView: MainView;
onViewChange: (view: MainView) => void;
actions: ReactNode;
}
export function AppHeader({ activeView, onViewChange, actions }: AppHeaderProps): ReactElement {
const navigation = buildMainNavigation(activeView);
return (
<header className="md-shell-header" data-ui-region="header">
<div className="md-shell-brand">Multi-Debrid-Downloader</div>
<nav aria-label="Hauptnavigation" className="md-shell-navigation" role="tablist">
{navigation.map((item) => (
<button
aria-current={item.active ? "page" : undefined}
aria-selected={item.active}
className={`md-shell-navigation-item${item.active ? " is-active" : ""}`}
data-visual-active-view={item.active ? item.id : undefined}
key={item.id}
onClick={() => onViewChange(item.id)}
role="tab"
title={item.label}
type="button"
>
<Icon name={item.icon} size={18} />
<span>{item.label}</span>
</button>
))}
</nav>
{actions ? <div aria-label="Globale Aktionen" className="md-shell-header-actions" role="group">{actions}</div> : null}
</header>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState, useSyncExternalStore, type ReactElement, type ReactNode } from "react";
import { ContextInfoButton } from "../ui/ContextInfoButton";
import { AppHeader } from "./AppHeader";
import { AppSidebar } from "./AppSidebar";
import {
getMainViewLabel,
getResponsiveShellMode,
type MainView,
type ResponsiveShellMode
} from "./shell-model";
import "./shell.css";
function subscribeToViewport(callback: () => void): () => void {
if (typeof window === "undefined") {
return () => undefined;
}
window.addEventListener("resize", callback);
return () => window.removeEventListener("resize", callback);
}
function getViewportShellMode(): ResponsiveShellMode {
return typeof window === "undefined" ? "full" : getResponsiveShellMode(window.innerWidth);
}
function getServerShellMode(): ResponsiveShellMode {
return "full";
}
export interface AppShellProps {
activeView: MainView;
onViewChange: (view: MainView) => void;
sidebar: ReactNode;
sidebarStatus: ReactNode;
headerActions: ReactNode;
toolbar: ReactNode;
children: ReactNode;
footer: ReactNode;
contextInfo: ReactNode;
sidebarCollapsed: boolean;
onSidebarCollapsedChange: (collapsed: boolean) => void;
}
export function AppShell({
activeView,
onViewChange,
sidebar,
sidebarStatus,
headerActions,
toolbar,
children,
footer,
contextInfo,
sidebarCollapsed,
onSidebarCollapsedChange
}: AppShellProps): ReactElement {
const [infoOpen, setInfoOpen] = useState(false);
const [responsiveSidebarExpanded, setResponsiveSidebarExpanded] = useState(false);
const responsiveMode = useSyncExternalStore(
subscribeToViewport,
getViewportShellMode,
getServerShellMode
);
const hasSidebar = Boolean(sidebar || sidebarStatus);
const responsiveSidebarCollapsed = responsiveMode !== "full" && !responsiveSidebarExpanded;
const effectiveSidebarCollapsed = sidebarCollapsed || responsiveSidebarCollapsed;
const setSidebarCollapsed = (collapsed: boolean): void => {
if (responsiveMode !== "full") {
setResponsiveSidebarExpanded(!collapsed);
}
onSidebarCollapsedChange(collapsed);
};
return (
<div
className={`md-shell is-${responsiveMode}${hasSidebar ? " has-sidebar" : ""}${hasSidebar && effectiveSidebarCollapsed ? " has-collapsed-sidebar" : ""}`}
data-responsive-mode={responsiveMode}
>
<AppHeader activeView={activeView} actions={headerActions} onViewChange={onViewChange} />
<div className="md-shell-workspace">
{hasSidebar ? (
<AppSidebar
collapsed={effectiveSidebarCollapsed}
onCollapsedChange={setSidebarCollapsed}
responsiveRail={responsiveSidebarCollapsed}
status={sidebarStatus}
>
{sidebar}
</AppSidebar>
) : null}
<section className="md-shell-main" data-ui-region="main">
{toolbar ? <div className="md-shell-toolbar" data-ui-region="toolbar">{toolbar}</div> : null}
<div className="md-shell-content">{children}</div>
{footer ? <div className="md-shell-footer" data-ui-region="footer">{footer}</div> : null}
<ContextInfoButton
content={contextInfo}
contextName={getMainViewLabel(activeView)}
onOpenChange={setInfoOpen}
open={infoOpen}
/>
</section>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactElement, ReactNode } from "react";
import { Icon } from "../ui/Icon";
export interface AppSidebarProps {
children: ReactNode;
status: ReactNode;
collapsed: boolean;
responsiveRail?: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}
export function AppSidebar({
children,
status,
collapsed,
responsiveRail = false,
onCollapsedChange
}: AppSidebarProps): ReactElement {
return (
<aside
className={`md-shell-sidebar${collapsed ? " is-collapsed" : ""}${responsiveRail ? " is-responsive-rail" : ""}`}
data-ui-region="sidebar"
>
{children ? <div className="md-shell-sidebar-scroll">{children}</div> : null}
{status ? <div className="md-shell-sidebar-status" data-ui-region="sidebar-status">{status}</div> : null}
<button
aria-label={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
className="md-shell-sidebar-toggle"
onClick={() => onCollapsedChange(!collapsed)}
title={collapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
type="button"
>
<Icon name={collapsed ? "chevron-right" : "chevron-left"} size={14} />
</button>
</aside>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, type ReactElement } from "react";
import { restoreFocus } from "../ui/focus";
export interface AvatarMenuAction {
id: string;
label: string;
danger?: boolean;
onSelect: () => void;
}
export interface AvatarMenuProps {
open: boolean;
accountLabel: string;
actions: AvatarMenuAction[];
onClose: () => void;
}
export type AvatarMenuKeyboardAction =
| { type: "close" }
| { type: "focus"; index: number };
export function getAvatarMenuKeyboardAction(
key: string,
currentIndex: number,
itemCount: number
): AvatarMenuKeyboardAction | null {
if (key === "Escape") {
return { type: "close" };
}
if (itemCount <= 0) {
return null;
}
if (key === "Home") {
return { type: "focus", index: 0 };
}
if (key === "End") {
return { type: "focus", index: itemCount - 1 };
}
if (key === "ArrowDown") {
return { type: "focus", index: (Math.max(currentIndex, -1) + 1) % itemCount };
}
if (key === "ArrowUp") {
return { type: "focus", index: currentIndex <= 0 ? itemCount - 1 : currentIndex - 1 };
}
return null;
}
export function AvatarMenu({ open, accountLabel, actions, onClose }: AvatarMenuProps): ReactElement | null {
const menuRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const closeAndRestoreFocus = (): void => {
const trigger = menuRef.current?.parentElement?.querySelector<HTMLButtonElement>('button[aria-haspopup="menu"]');
onClose();
restoreFocus(trigger ?? null);
};
useEffect(() => {
if (!open) {
return;
}
const onPointerDown = (event: MouseEvent): void => {
if (!menuRef.current?.parentElement?.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", onPointerDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
};
}, [onClose, open]);
if (!open) {
return null;
}
return (
<div
aria-label="Kontomenü"
className="md-avatar-menu"
onKeyDown={(event) => {
const currentIndex = itemRefs.current.findIndex((item) => item === document.activeElement);
const action = getAvatarMenuKeyboardAction(event.key, currentIndex, actions.length);
if (!action) {
return;
}
event.preventDefault();
event.stopPropagation();
if (action.type === "close") {
closeAndRestoreFocus();
return;
}
itemRefs.current[action.index]?.focus();
}}
ref={menuRef}
role="menu"
>
<div className="md-avatar-menu-account">{accountLabel}</div>
{actions.map((action, index) => (
<button
autoFocus={index === 0}
className={`md-avatar-menu-action${action.danger ? " is-danger" : ""}`}
key={action.id}
onClick={() => {
action.onSelect();
closeAndRestoreFocus();
}}
ref={(element) => {
itemRefs.current[index] = element;
}}
role="menuitem"
type="button"
>
{action.label}
</button>
))}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { ReactElement, ReactNode } from "react";
export interface OverlayHostProps {
confirm?: ReactNode;
onlineBackup?: ReactNode;
diagnostics?: ReactNode;
deleteConfirmation?: ReactNode;
conflict?: ReactNode;
accountCreate?: ReactNode;
accountEdit?: ReactNode;
keyStats?: ReactNode;
linkPopup?: ReactNode;
update?: ReactNode;
toast?: ReactNode;
accountContextMenu?: ReactNode;
downloadContextMenu?: ReactNode;
columnContextMenu?: ReactNode;
historyContextMenu?: ReactNode;
dropOverlay?: ReactNode;
}
export function OverlayHost({
confirm,
onlineBackup,
diagnostics,
deleteConfirmation,
conflict,
accountCreate,
accountEdit,
keyStats,
linkPopup,
update,
toast,
accountContextMenu,
downloadContextMenu,
columnContextMenu,
historyContextMenu,
dropOverlay
}: OverlayHostProps): ReactElement {
return (
<div className="md-overlay-host" id="md-overlay-host">
{confirm}
{onlineBackup}
{diagnostics}
{deleteConfirmation}
{conflict}
{accountCreate}
{accountEdit}
{keyStats}
{linkPopup}
{update}
{toast}
{accountContextMenu}
{downloadContextMenu}
{columnContextMenu}
{historyContextMenu}
{dropOverlay}
</div>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { useState, type ReactElement } from "react";
import type { UpdateInstallProgress } from "../../shared/types";
import { Dialog } from "../ui/Dialog";
export { getDialogFocusTarget as getUpdateDialogFocusTarget } from "../ui/Dialog";
export type UpdateExperienceState = "prompt" | UpdateInstallProgress["stage"];
export interface UpdateExperienceProgress {
percent: number | null;
text: string;
}
export interface UpdateExperienceProps {
available: boolean;
latestTag: string;
currentVersion: string;
releaseNotes: string;
state: UpdateExperienceState;
progress: UpdateExperienceProgress | number | null;
open: boolean;
onOpen: () => void;
onClose: () => void;
onInstall: () => void;
onLater: () => void;
renderTrigger?: boolean;
renderDialog?: boolean;
}
const activeStates = new Set<UpdateExperienceState>([
"starting",
"downloading",
"verifying",
"launching"
]);
export function UpdateExperience({
available,
latestTag,
currentVersion,
releaseNotes,
state,
progress,
open,
onOpen,
onClose,
onInstall,
onLater,
renderTrigger = true,
renderDialog = true
}: UpdateExperienceProps): ReactElement | null {
const [tooltipOpen, setTooltipOpen] = useState(false);
const progressInfo = typeof progress === "object" ? progress : null;
const active = activeStates.has(state);
const closable = !active;
if (!available && !open) {
return null;
}
return (
<>
{available && renderTrigger ? (
<div
className="md-update-anchor"
onBlur={() => setTooltipOpen(false)}
onFocus={() => setTooltipOpen(true)}
onMouseEnter={() => setTooltipOpen(true)}
onMouseLeave={() => setTooltipOpen(false)}
>
<button
aria-describedby="md-update-tooltip"
aria-label="Update verfügbar"
className="md-update-trigger"
onClick={onOpen}
type="button"
>
Update verfügbar
</button>
<div
aria-label="Update verfügbar"
className={`md-update-tooltip${tooltipOpen ? " is-visible" : ""}`}
id="md-update-tooltip"
role="tooltip"
>
Eine neue Version ist bereit. Klicke hier, um sie zu installieren.
</div>
</div>
) : null}
{renderDialog ? (
<Dialog
actions={state === "prompt" ? (
<>
<button className="btn" onClick={onLater} type="button">Später</button>
<button className="btn primary" onClick={onInstall} type="button">Jetzt aktualisieren</button>
</>
) : null}
actionsClassName="md-update-dialog-actions"
backdropClassName="md-update-backdrop"
bodyClassName="md-update-dialog-body"
className={`md-update-dialog md-update-dialog-${state}`}
closable={closable}
headerClassName="md-update-dialog-header"
onClose={onClose}
open={open}
showCloseButton
size="update"
title="Update installieren"
>
{state === "prompt" ? (
<>
<p>{latestTag} ist verfügbar. Installierte Version: {currentVersion}.</p>
{releaseNotes.trim() ? (
<details className="md-update-release-notes">
<summary>Changelog anzeigen</summary>
<pre>{releaseNotes}</pre>
</details>
) : null}
</>
) : (
<>
<p className="md-update-progress-text">{progressInfo?.text ?? "Update wird vorbereitet..."}</p>
{state === "downloading" && progressInfo?.percent !== null && progressInfo?.percent !== undefined ? (
<div
aria-label="Update-Fortschritt"
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={progressInfo.percent}
className="md-update-progress-track"
role="progressbar"
>
<div className="md-update-progress-fill" style={{ width: `${progressInfo.percent}%` }} />
</div>
) : null}
</>
)}
</Dialog>
) : null}
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { IconName } from "../ui/Icon";
export type MainView = "downloads" | "collector" | "settings" | "history" | "statistics";
export type ResponsiveShellMode = "full" | "compact" | "minimum";
export interface NavigationItem {
id: MainView;
label: string;
icon: IconName;
active: boolean;
}
const MAIN_NAVIGATION = [
{ id: "downloads", label: "Downloads", icon: "download" },
{ id: "collector", label: "Linksammler", icon: "collector" },
{ id: "settings", label: "Einstellungen", icon: "settings" },
{ id: "history", label: "Verlauf", icon: "history" },
{ id: "statistics", label: "Statistiken", icon: "statistics" }
] as const;
export function buildMainNavigation(active: MainView): NavigationItem[] {
return MAIN_NAVIGATION.map((item) => ({ ...item, active: item.id === active }));
}
export function getMainViewLabel(view: MainView): string {
return MAIN_NAVIGATION.find((item) => item.id === view)?.label ?? view;
}
export function getResponsiveShellMode(width: number): ResponsiveShellMode {
if (width <= 1120) {
return "minimum";
}
if (width <= 1366) {
return "compact";
}
return "full";
}
+822
View File
@@ -0,0 +1,822 @@
.md-runtime-root {
--md-layer-avatar: 500;
--md-layer-menu: 600;
--md-layer-tooltip: 700;
--md-layer-toast: 800;
--md-layer-modal: 1000;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.md-runtime-root.settings-active {
user-select: none;
}
.md-runtime-root.settings-active input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
.md-runtime-root.settings-active textarea,
.md-runtime-root.settings-active [contenteditable]:not([contenteditable="false"]) {
user-select: text;
}
.md-shell {
display: grid;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
grid-template-rows: 40px minmax(0, 1fr);
gap: 8px;
padding: 8px 16px 16px;
overflow: hidden;
background: var(--ui-canvas);
color: var(--ui-text);
}
.md-shell-header {
position: relative;
display: flex;
min-width: 0;
height: 40px;
align-items: center;
gap: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.md-shell-brand {
flex: 0 0 auto;
padding: 0 16px;
color: var(--ui-text);
font-size: 16px;
font-weight: 700;
line-height: 20px;
}
.md-shell-navigation {
display: flex;
min-width: 0;
flex: 1 1 auto;
height: 100%;
align-items: center;
gap: 2px;
overflow: hidden;
}
.md-shell-navigation-item {
display: inline-flex;
height: 32px;
padding: 0 10px;
align-items: center;
gap: 8px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font-weight: 600;
}
.md-shell-navigation-item:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-shell-navigation-item.is-active {
background: var(--ui-active);
color: var(--ui-text);
}
.md-shell-navigation-item:focus-visible,
.md-shell-header-actions button:focus-visible,
.md-shell-sidebar-toggle:focus-visible,
.md-avatar-menu-action:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.md-shell-header-actions {
position: relative;
display: flex;
min-width: 0;
flex: 0 0 auto;
height: 100%;
margin-left: auto;
align-items: center;
gap: 4px;
padding-right: 6px;
}
.md-update-anchor {
position: relative;
display: flex;
height: 100%;
align-items: center;
}
.md-update-trigger {
display: inline-flex;
height: 40px;
min-width: 90px;
padding: 0 14px;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: var(--ui-primary);
color: #0f0f0f;
cursor: pointer;
font-size: 14px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
}
.md-update-trigger:hover {
background: var(--ui-primary-hover);
}
.md-update-trigger:focus-visible,
.md-update-dialog button:focus-visible,
.md-update-release-notes summary:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.md-update-tooltip {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: var(--md-layer-tooltip);
display: flex;
width: 200px;
min-height: 68px;
padding: 12px;
align-items: center;
box-sizing: border-box;
border-radius: 8px;
background: #4f4d4d;
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: #fff;
font-size: 12px;
line-height: 16px;
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.md-update-anchor:hover .md-update-tooltip,
.md-update-anchor:focus-within .md-update-tooltip,
.md-update-tooltip.is-visible {
opacity: 1;
visibility: visible;
}
.md-update-backdrop {
position: fixed;
inset: 0;
z-index: 1300;
display: grid;
padding: 16px;
place-items: center;
background: rgb(0 0 0 / 60%);
}
.md-update-dialog {
display: grid;
width: min(548px, calc(100vw - 32px));
min-height: 207px;
max-height: calc(100vh - 32px);
grid-template-rows: 56px minmax(92px, auto) 58px;
overflow: auto;
transform: translateY(-24px);
border: 1px solid var(--ui-border);
border-radius: 16px;
background: var(--ui-surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
color: var(--ui-text);
}
.md-update-dialog:not(.md-update-dialog-prompt) {
grid-template-rows: 56px minmax(150px, auto);
}
.md-update-dialog:focus {
outline: none;
}
.md-update-dialog-header {
display: flex;
min-width: 0;
height: 56px;
padding: 0 16px 0 20px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--ui-border);
}
.md-update-dialog-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.md-update-dialog-close {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font-size: 24px;
line-height: 24px;
}
.md-update-dialog-close:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-update-dialog-body {
min-width: 0;
padding: 16px 20px;
overflow: auto;
}
.md-update-dialog-body p {
margin: 0;
color: var(--ui-text-secondary);
line-height: 20px;
}
.md-update-release-notes {
margin-top: 10px;
color: var(--ui-text-secondary);
}
.md-update-release-notes summary {
width: max-content;
cursor: pointer;
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.md-update-release-notes pre {
margin: 10px 0 0;
color: var(--ui-text-secondary);
font: inherit;
line-height: 20px;
white-space: pre-wrap;
}
.md-update-dialog-actions {
display: flex;
min-height: 58px;
padding: 10px 16px;
align-items: center;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--ui-border);
}
.md-update-progress-text {
min-height: 20px;
}
.md-update-progress-track {
height: 8px;
margin-top: 16px;
overflow: hidden;
border-radius: 999px;
background: var(--ui-input);
}
.md-update-progress-fill {
height: 100%;
border-radius: inherit;
background: var(--ui-accent);
}
.md-application-menu-tree {
display: flex;
height: 100%;
align-items: center;
user-select: none;
}
.md-application-menu-tree .menu-bar-trigger {
height: 32px;
padding: 0 8px;
}
.md-download-sidebar-content {
display: grid;
gap: 12px;
}
.md-download-sidebar-search {
display: grid;
gap: 4px;
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.md-download-sidebar-search input {
width: 100%;
}
.md-download-sidebar-actions {
display: grid;
gap: 4px;
}
.md-download-sidebar-actions .btn {
width: 100%;
text-align: left;
}
.md-runtime-view-content {
display: flex;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.md-runtime-view-content > * {
width: 100%;
min-width: 0;
min-height: 0;
}
.md-shell-workspace {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
}
.md-shell.has-sidebar .md-shell-workspace {
grid-template-columns: 270px minmax(0, 1fr);
}
.md-shell.has-collapsed-sidebar .md-shell-workspace {
grid-template-columns: 56px minmax(0, 1fr);
}
.md-shell-sidebar,
.md-shell-main {
position: relative;
min-width: 0;
min-height: 0;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.md-shell-sidebar {
display: flex;
flex-direction: column;
}
.md-shell-sidebar-scroll {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
padding: 12px;
}
.md-shell-sidebar-status {
display: grid;
flex: 0 0 auto;
gap: 4px;
padding: 12px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 12px;
line-height: 16px;
}
.md-shell-sidebar.is-collapsed :where(.md-shell-sidebar-scroll, .md-shell-sidebar-status) {
overflow: hidden;
visibility: hidden;
}
.md-shell-sidebar-toggle {
position: absolute;
top: 50%;
right: -8px;
z-index: 5;
display: grid;
width: 14px;
height: 42px;
padding: 0;
place-items: center;
transform: translateY(-50%);
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-input);
color: var(--ui-text-muted);
cursor: pointer;
opacity: 0;
}
.md-shell-sidebar.is-responsive-rail .md-shell-sidebar-toggle {
top: 12px;
right: auto;
left: 11px;
width: 32px;
height: 32px;
transform: none;
opacity: 1;
}
.md-shell-sidebar:hover .md-shell-sidebar-toggle,
.md-shell-sidebar-toggle:focus-visible {
opacity: 1;
}
.md-shell-main {
display: flex;
overflow: hidden;
flex-direction: column;
}
.md-shell-toolbar {
min-width: 0;
flex: 0 0 auto;
overflow-x: auto;
overflow-y: hidden;
}
.md-shell-content {
min-width: 0;
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
}
.md-shell-footer {
flex: 0 0 auto;
border-top: 1px solid var(--ui-border);
}
.md-avatar-anchor {
position: relative;
}
.md-avatar-trigger {
display: grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 0;
border-radius: 50%;
background: var(--ui-active);
color: var(--ui-text-secondary);
cursor: pointer;
}
.md-avatar-menu {
position: absolute;
top: calc(100% + 5px);
right: 0;
z-index: var(--md-layer-avatar);
display: flex;
width: 174px;
min-height: 106px;
padding: 8px;
flex-direction: column;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.md-avatar-menu-account {
overflow: hidden;
padding: 4px 8px 8px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.md-avatar-menu-action {
min-height: 30px;
padding: 5px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
text-align: left;
}
.md-avatar-menu-action:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-avatar-menu-action.is-danger {
color: var(--ui-danger);
}
.md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) {
z-index: var(--md-layer-menu);
}
.md-overlay-host {
position: static;
}
.md-dialog-backdrop {
position: fixed;
inset: 0;
z-index: var(--md-layer-modal);
display: grid;
padding: 20px;
place-items: center;
background: var(--ui-overlay);
}
.md-dialog {
display: grid;
width: min(560px, calc(100vw - 40px));
max-height: calc(100vh - 40px);
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 0;
overflow: hidden;
padding: 0;
border: 1px solid var(--ui-border);
border-radius: 16px;
outline: none;
background: var(--ui-surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
color: var(--ui-text);
}
.md-dialog.md-dialog-size-account {
width: min(660px, calc(100vw - 40px));
}
.md-dialog.md-dialog-size-wide {
width: min(760px, calc(100vw - 40px));
}
.md-dialog.md-dialog-size-update {
width: min(548px, calc(100vw - 32px));
}
.md-dialog-header {
display: flex;
min-width: 0;
min-height: 56px;
padding: 12px 16px 12px 20px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--ui-border);
}
.md-dialog-header h2 {
margin: 0;
color: var(--ui-text);
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.md-dialog-close {
display: grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
padding: 0;
place-items: center;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font-size: 24px;
line-height: 24px;
}
.md-dialog-close:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.md-dialog-body {
min-width: 0;
min-height: 0;
overflow: auto;
padding: 16px 20px;
}
.md-dialog-body > :first-child {
margin-top: 0;
}
.md-dialog-body > :last-child {
margin-bottom: 0;
}
.md-dialog-description,
.md-dialog-body p {
color: var(--ui-text-secondary);
}
.md-dialog-actions {
min-height: 58px;
padding: 10px 16px;
border-top: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.md-dialog :where(button, summary, input, select, textarea):focus-visible,
.md-context-menu [role="menuitem"]:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.md-context-menu {
z-index: var(--md-layer-menu);
max-width: calc(100vw - 8px);
max-height: calc(100vh - 8px);
overflow: auto;
border-color: var(--ui-border);
background: var(--ui-surface);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: var(--ui-text);
}
.md-context-menu .ctx-menu-item {
color: var(--ui-text);
}
.md-context-menu .ctx-menu-item:hover,
.md-context-menu .ctx-menu-item:focus-visible {
background: var(--ui-hover);
}
.md-context-menu .ctx-menu-item.ctx-danger {
color: var(--ui-danger);
}
.md-context-menu .ctx-menu-sep {
background: var(--ui-border);
}
.md-context-menu .ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items {
display: block;
}
.md-toast {
right: 20px;
bottom: 84px;
z-index: var(--md-layer-toast);
max-width: min(420px, calc(100vw - 40px));
border-color: var(--ui-border);
background: var(--ui-surface);
color: var(--ui-text);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
}
.md-drop-overlay {
z-index: var(--md-layer-modal);
pointer-events: none;
background: var(--ui-overlay);
border-color: color-mix(in srgb, var(--ui-accent) 72%, transparent);
color: var(--ui-text);
}
.md-overlay-host .md-dialog-backdrop {
z-index: var(--md-layer-modal);
padding: 20px;
background: var(--ui-overlay);
}
.md-overlay-host .md-dialog {
gap: 0;
overflow: hidden;
padding: 0;
border-color: var(--ui-border);
border-radius: 16px;
background: var(--ui-surface);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
color: var(--ui-text);
}
.md-overlay-host .md-context-menu {
z-index: var(--md-layer-menu);
border-color: var(--ui-border);
background: var(--ui-surface);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: var(--ui-text);
}
.md-overlay-host .md-toast {
bottom: 84px;
z-index: var(--md-layer-toast);
border-color: var(--ui-border);
background: var(--ui-surface);
box-shadow: 0 4px 12px rgb(0 0 0 / 35%);
color: var(--ui-text);
}
.md-overlay-host .md-drop-overlay {
z-index: var(--md-layer-modal);
pointer-events: none;
background: var(--ui-overlay);
color: var(--ui-text);
}
@media (max-width: 1366px) {
.md-shell.is-compact .md-shell-navigation-item span,
.md-shell.is-compact .md-shell-brand,
.md-shell.is-minimum .md-shell-navigation-item span,
.md-shell.is-minimum .md-shell-brand {
display: none;
}
.md-shell.is-compact .md-shell-header,
.md-shell.is-minimum .md-shell-header {
gap: 6px;
}
.md-shell.is-compact .md-shell-navigation,
.md-shell.is-minimum .md-shell-navigation {
flex: 1 1 auto;
overflow: hidden;
}
.md-shell.is-compact .md-shell-navigation-item,
.md-shell.is-minimum .md-shell-navigation-item {
width: 36px;
flex: 0 0 36px;
padding-inline: 9px;
justify-content: center;
}
.md-shell.is-compact .md-shell-header-actions,
.md-shell.is-minimum .md-shell-header-actions {
flex: 0 0 auto;
}
}
@media (max-width: 1120px) {
.md-shell.is-minimum {
gap: 6px;
padding: 6px 8px 8px;
}
.md-shell.is-minimum .md-shell-workspace {
gap: 6px;
}
.md-dialog-backdrop,
.md-overlay-host .md-dialog-backdrop,
.md-update-backdrop {
padding: 12px;
}
.md-dialog,
.md-dialog.md-dialog-size-account,
.md-dialog.md-dialog-size-wide,
.md-dialog.md-dialog-size-update,
.md-update-dialog {
max-width: 100%;
max-height: calc(100vh - 24px);
}
.md-dialog-actions,
.md-update-dialog-actions {
flex-wrap: wrap;
}
}
@media (prefers-reduced-motion: reduce) {
.md-shell *,
.md-shell *::before,
.md-shell *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+1 -196
View File
@@ -933,27 +933,6 @@ body,
padding: 7px 9px; padding: 7px 9px;
} }
.collector-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.collector-metrics {
color: var(--muted);
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.collector-tabs {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.collector-tab { .collector-tab {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2221,8 +2200,7 @@ body,
white-space: nowrap; white-space: nowrap;
} }
.account-picker-icon, .account-picker-icon {
.acct2-service-icon {
display: block; display: block;
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -2366,27 +2344,6 @@ body,
padding: 6px 10px; padding: 6px 10px;
} }
.queue-package-card {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
box-shadow: none;
border-bottom: 1px solid color-mix(in srgb, var(--border) 54%, transparent);
}
.queue-package-card.pkg-stripe-a {
background: color-mix(in srgb, var(--surface) 18%, transparent);
}
.queue-package-card.pkg-stripe-b {
background: color-mix(in srgb, var(--card) 24%, transparent);
}
.queue-package-card:hover {
background: color-mix(in srgb, var(--accent) 3%, transparent);
}
.package-card[draggable="true"] { .package-card[draggable="true"] {
cursor: grab; cursor: grab;
} }
@@ -2404,12 +2361,6 @@ body,
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
} }
.queue-package-card.pkg-selected {
border-color: transparent;
box-shadow: inset 2px 0 0 0 var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.item-selected { .item-selected {
background: color-mix(in srgb, var(--accent) 12%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent);
} }
@@ -2421,11 +2372,6 @@ body,
align-items: center; align-items: center;
} }
.queue-package-card header {
min-height: 28px;
padding: 3px 10px;
}
.package-card h4 { .package-card h4 {
margin: 0; margin: 0;
font-size: 14px; font-size: 14px;
@@ -2460,15 +2406,6 @@ body,
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
} }
.queue-package-card .pkg-toggle {
width: 16px;
height: 16px;
border-radius: 3px;
font-size: 12px;
background: transparent;
border-color: color-mix(in srgb, var(--border) 42%, transparent);
}
.pkg-toggle:hover { .pkg-toggle:hover {
border-color: var(--accent); border-color: var(--accent);
color: var(--text); color: var(--text);
@@ -2516,13 +2453,6 @@ body,
display: flex; display: flex;
} }
.queue-package-card .progress {
margin-top: 0;
height: 2px;
border-radius: 0;
background: color-mix(in srgb, var(--progress-track) 80%, transparent);
}
.progress-dl { .progress-dl {
height: 100%; height: 100%;
background: linear-gradient(90deg, #f2942d, #ff7a5c); background: linear-gradient(90deg, #f2942d, #ff7a5c);
@@ -2566,10 +2496,6 @@ body,
margin-left: auto; margin-left: auto;
} }
.history-card {
cursor: default;
}
.history-details { .history-details {
padding: 10px 12px; padding: 10px 12px;
border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
@@ -2642,16 +2568,6 @@ td {
border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent); border-top: 1px solid color-mix(in srgb, var(--border) 40%, transparent);
} }
.queue-package-card .item-row {
margin: 0;
padding: 2px 10px 2px 10px;
border-top: 0;
}
.queue-package-card .item-row + .item-row {
border-top: 1px solid color-mix(in srgb, var(--border) 24%, transparent);
}
.item-row:hover { .item-row:hover {
background: color-mix(in srgb, var(--accent) 5%, transparent); background: color-mix(in srgb, var(--accent) 5%, transparent);
} }
@@ -2780,16 +2696,6 @@ td {
background: rgba(244, 63, 94, 0.1); background: rgba(244, 63, 94, 0.1);
} }
.statistics-view {
height: 100%;
overflow: auto;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
gap: 10px;
min-height: 0;
}
.stats-overview { .stats-overview {
grid-column: span 2; grid-column: span 2;
} }
@@ -2947,15 +2853,6 @@ td {
flex-direction: column; flex-direction: column;
} }
.provider-stats {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 8px;
}
.provider-stat-item { .provider-stat-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -3014,11 +2911,6 @@ td {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.statistics-view {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.stats-overview { .stats-overview {
grid-column: span 1; grid-column: span 1;
} }
@@ -3476,93 +3368,6 @@ td {
} }
.account-board-header-actions { display: flex; gap: 8px; align-items: center; } .account-board-header-actions { display: flex; gap: 8px; align-items: center; }
.account-validity-badge {
display: inline-block;
margin-top: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.2px;
border: 1px solid transparent;
white-space: nowrap;
}
.account-validity-badge.ok { color: #10240f; background: #4fb96a; border-color: #3f9d57; }
.account-validity-badge.free { color: #2a2113; background: #f2c14e; border-color: #d9a72f; }
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
.account-validity-badge.disabled { color: var(--muted, #a59c8e); background: transparent; border-color: var(--border); opacity: 0.8; }
.acct2-table {
display: block;
flex: 1;
min-height: 0;
border-bottom: 1px solid var(--border);
overflow: auto;
}
.acct2-head,
.acct2-row {
display: grid;
grid-template-columns: 24px minmax(170px, 1.25fr) minmax(210px, 1.5fr) minmax(180px, 1.3fr) minmax(180px, 1.3fr) minmax(110px, 0.72fr) minmax(145px, 0.9fr) 30px;
align-items: center;
gap: 6px;
min-width: 1120px;
padding: 0 6px;
}
.acct2-head > span,
.acct2-row > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.acct2-head > .acct2-c-actions,
.acct2-row > .acct2-c-actions { overflow: visible; }
.acct2-head {
position: sticky;
top: 0;
z-index: 2;
min-height: 25px;
background: color-mix(in srgb, var(--card) 60%, transparent);
border-bottom: 1px solid var(--border);
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.acct2-row {
min-height: 28px;
border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
font-size: 14px;
line-height: 1.2;
cursor: default;
}
.acct2-row:last-child { border-bottom: 0; }
.acct2-row:hover { background: color-mix(in srgb, var(--button-bg-hover) 55%, transparent); }
.acct2-row.selected { background: color-mix(in srgb, var(--accent) 15%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); }
.acct2-row.acct2-problem { background: color-mix(in srgb, var(--danger) 15%, transparent); }
.acct2-row.acct2-problem.selected { background: color-mix(in srgb, var(--danger) 20%, var(--accent) 8%); }
.acct2-row.acct2-disabled { opacity: 0.55; }
.acct2-body { min-width: 1120px; }
.acct2-empty { display: grid; place-items: center; min-height: 160px; color: var(--muted); font-size: 11px; }
.acct2-c-check { display: flex; justify-content: center; }
.acct2-c-check input { cursor: pointer; }
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
.acct2-menu-button { width: 22px; min-width: 22px; height: 19px; padding: 0; font-size: 14px; line-height: 1; }
.acct2-hoster { display: grid; grid-template-columns: 18px auto minmax(0, 1fr); align-items: center; gap: 6px; min-width: 0; }
.acct2-hoster strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-mode { overflow: hidden; color: var(--muted); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-traffic,
.acct2-expiry { font-variant-numeric: tabular-nums; color: var(--muted); }
.acct2-head > span:nth-child(4),
.acct2-traffic { text-align: center; }
.acct2-sortable { width: 100%; padding: 0; border: 0; color: inherit; background: transparent; font: inherit; letter-spacing: inherit; text-align: left; text-transform: inherit; cursor: pointer; user-select: none; }
.acct2-sortable:hover { color: var(--text); }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-credential { color: var(--muted); white-space: nowrap; }
.acct2-status .account-validity-badge { max-width: 100%; height: 20px; margin-top: 0; padding: 0 7px; overflow: hidden; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; }
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
.account-edit-modal { .account-edit-modal {
width: min(600px, calc(100vw - 36px)); width: min(600px, calc(100vw - 36px));
max-height: calc(100vh - 36px); max-height: calc(100vh - 36px);
+392
View File
@@ -0,0 +1,392 @@
:root,
:root[data-theme="dark"] {
--ui-canvas: #0F0F0F;
--ui-surface: #232323;
--ui-input: #2B2B2B;
--ui-table-header: #313131;
--ui-active: #333436;
--ui-hover: #373535;
--ui-tooltip: #4F4D4D;
--ui-border: #3D3D3D;
--ui-text: #FFFFFF;
--ui-text-secondary: #EAEDF3;
--ui-text-muted: #919191;
--ui-primary: #BAD0FC;
--ui-primary-hover: #8AA5DC;
--ui-accent: #3886FF;
--ui-warning: #F1C786;
--ui-danger: #F06464;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #35383D;
--ui-overlay: rgba(0, 0, 0, 0.60);
color-scheme: dark;
}
:root[data-theme="light"] {
--ui-canvas: #F3F4F6;
--ui-surface: #FFFFFF;
--ui-input: #F7F8FA;
--ui-table-header: #E7E9ED;
--ui-active: #DEE6F5;
--ui-hover: #E8ECF3;
--ui-tooltip: #35383D;
--ui-border: #D0D4DB;
--ui-text: #181A1F;
--ui-text-secondary: #343842;
--ui-text-muted: #667085;
--ui-primary: #A9C2F3;
--ui-primary-hover: #8AA5DC;
--ui-accent: #256FDB;
--ui-warning: #E8B85D;
--ui-danger: #D94747;
--ui-error-action-text: #181A1F;
--ui-modal-secondary: #E7E9ED;
--ui-overlay: rgba(0, 0, 0, 0.45);
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
min-width: 0;
min-height: 100%;
}
body {
margin: 0;
background: var(--ui-canvas);
color: var(--ui-text);
font-family: Inter, "Segoe UI Variable", "Segoe UI", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 20px;
text-rendering: optimizeLegibility;
user-select: none;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
color: inherit;
}
input,
textarea,
[contenteditable="true"] {
user-select: text;
}
* {
scrollbar-color: var(--ui-border) transparent;
scrollbar-width: thin;
}
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: 999px;
background: var(--ui-border);
background-clip: padding-box;
}
*::-webkit-scrollbar-track {
background: transparent;
}
.ui-icon {
display: block;
flex: 0 0 auto;
pointer-events: none;
}
.ui-input {
min-width: 0;
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 6px;
outline: none;
background: var(--ui-input);
color: var(--ui-text);
}
.ui-input::placeholder {
color: var(--ui-text-muted);
opacity: 1;
}
.ui-input:disabled {
cursor: not-allowed;
opacity: 0.55;
}
:where(
button,
[href],
input,
select,
textarea,
summary,
[tabindex]:not([tabindex="-1"])
):focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.ui-toolbar {
display: flex;
min-width: 0;
min-height: 59px;
align-items: center;
gap: 8px;
padding: 11px 12px 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.ui-toolbar-group {
display: flex;
min-width: 0;
align-items: center;
gap: 4px;
}
.ui-toolbar-search {
position: relative;
display: flex;
width: min(280px, 100%);
margin-left: auto;
align-items: center;
}
.ui-toolbar-search-icon {
position: absolute;
left: 10px;
color: var(--ui-text-muted);
}
.ui-toolbar-search-input {
width: 100%;
padding: 7px 12px 7px 34px;
}
.ui-data-table {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
background: var(--ui-surface);
color: var(--ui-text);
}
.ui-data-table-header {
min-height: 41px;
flex: 0 0 41px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
}
.ui-data-table-body {
position: relative;
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: auto;
}
.ui-data-table-empty {
display: flex;
min-height: 100%;
flex: 1 1 auto;
}
.ui-data-table-empty-cell {
display: flex;
max-width: 440px;
margin: auto;
align-items: center;
flex-direction: column;
gap: 8px;
padding: 32px 24px;
color: var(--ui-text-muted);
text-align: center;
}
.ui-data-table-empty-illustration {
display: grid;
width: 48px;
height: 48px;
margin-bottom: 4px;
place-items: center;
color: var(--ui-text-secondary);
}
.ui-data-table-empty-title {
color: var(--ui-text);
font-size: 18px;
font-weight: 600;
line-height: 24px;
}
.ui-data-table-empty-description {
color: var(--ui-text-muted);
}
.ui-data-table-footer {
display: flex;
min-height: 61px;
flex: 0 0 61px;
align-items: center;
justify-content: flex-end;
gap: 24px;
padding: 0 16px;
border-top: 1px solid var(--ui-border);
background: var(--ui-surface);
color: var(--ui-text-secondary);
font-size: 12px;
line-height: 16px;
}
.ui-context-info {
position: absolute;
bottom: 16px;
left: 16px;
z-index: 20;
display: flex;
align-items: flex-end;
gap: 8px;
}
.ui-context-info-trigger {
display: inline-grid;
width: 32px;
height: 32px;
padding: 0;
place-items: center;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-surface);
color: var(--ui-text-secondary);
cursor: pointer;
}
.ui-context-info-trigger:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.ui-context-info-region {
width: min(360px, calc(100vw - 80px));
max-height: min(320px, calc(100vh - 96px));
overflow: auto;
padding: 12px 16px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-tooltip);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
color: #FFFFFF;
}
.ui-error-boundary {
position: fixed;
inset: 0;
z-index: 10000;
overflow: auto;
padding: clamp(24px, 5vw, 48px);
background: var(--ui-canvas);
color: var(--ui-text);
text-align: center;
}
.ui-error-boundary-content {
display: flex;
width: min(640px, 100%);
min-height: 100%;
margin: auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
.ui-error-boundary-title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.ui-error-boundary-description {
max-width: 560px;
margin: 0;
color: var(--ui-text-secondary);
}
.ui-error-boundary-details {
width: 100%;
max-height: min(200px, 40vh);
margin: 0;
overflow: auto;
padding: 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-danger);
font-size: 12px;
line-height: 18px;
overflow-wrap: anywhere;
text-align: left;
user-select: text;
white-space: pre-wrap;
}
.ui-error-boundary-reload {
min-height: 40px;
padding: 8px 20px;
border: 0;
border-radius: 6px;
background: var(--ui-primary);
color: var(--ui-error-action-text);
cursor: pointer;
font-weight: 600;
}
.ui-error-boundary-reload:hover {
background: var(--ui-primary-hover);
}
.ui-error-boundary-details:focus-visible,
.ui-error-boundary-reload:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { Children, Fragment, isValidElement, useId, type ReactElement, type ReactNode } from "react";
import { Icon } from "./Icon";
export interface ContextInfoButtonProps {
contextName: string;
content: ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
}
function hasRenderableContent(content: ReactNode): boolean {
if (content === null || content === undefined || typeof content === "boolean") {
return false;
}
if (typeof content === "string") {
return content.trim().length > 0;
}
if (typeof content === "number") {
return true;
}
if (Array.isArray(content)) {
return content.some(hasRenderableContent);
}
if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) {
return hasRenderableContent(content.props.children);
}
if (isValidElement(content)) {
return true;
}
return Children.toArray(content).some(hasRenderableContent);
}
export function ContextInfoButton({
contextName,
content,
open,
onOpenChange
}: ContextInfoButtonProps): ReactElement | null {
const regionId = useId();
if (!hasRenderableContent(content)) {
return null;
}
return (
<div className="ui-context-info">
<button
aria-controls={regionId}
aria-expanded={open}
aria-label="Informationen"
className="ui-context-info-trigger"
onClick={() => onOpenChange(!open)}
title="Informationen"
type="button"
>
<Icon name="info" size={18} />
</button>
{open ? (
<div
aria-label={`Informationen zu ${contextName}`}
className="ui-context-info-region"
id={regionId}
role="region"
>
{content}
</div>
) : null}
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject
} from "react";
import { restoreFocus } from "./focus";
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
export interface ContextMenuProps {
open: boolean;
x: number;
y: number;
onClose: () => void;
children: ReactNode;
ariaLabel?: string;
className?: string;
ignoreOutsideRefs?: Array<RefObject<HTMLElement>>;
}
export type ContextMenuKeyboardAction =
| { type: "focus"; index: number }
| { type: "activate"; index: number }
| { type: "close" };
export type ContextMenuSubmenuKeyboardAction = "open" | "close";
export function clampContextMenuPosition(
x: number,
y: number,
width: number,
height: number,
viewportWidth: number,
viewportHeight: number
): { x: number; y: number } {
return {
x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))),
y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height)))
};
}
export function getContextSubmenuPosition(
trigger: { left: number; right: number; top: number },
submenu: { width: number; height: number },
viewport: { width: number; height: number }
): { x: number; y: number } {
const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0;
return clampContextMenuPosition(
opensRight ? trigger.right : trigger.left - submenu.width,
trigger.top,
submenu.width,
submenu.height,
viewport.width,
viewport.height
);
}
export function getContextMenuKeyboardAction(
key: string,
currentIndex: number,
enabled: boolean[]
): ContextMenuKeyboardAction | null {
const indexes = enabled.flatMap((value, index) => value ? [index] : []);
if (key === "Escape") {
return { type: "close" };
}
if (indexes.length === 0) {
return null;
}
if (key === "Enter" || key === " ") {
return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] };
}
if (key === "Home") {
return { type: "focus", index: indexes[0] };
}
if (key === "End") {
return { type: "focus", index: indexes[indexes.length - 1] };
}
if (key !== "ArrowDown" && key !== "ArrowUp") {
return null;
}
const enabledPosition = indexes.indexOf(currentIndex);
if (enabledPosition < 0) {
return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] };
}
const direction = key === "ArrowDown" ? 1 : -1;
const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length;
return { type: "focus", index: indexes[nextPosition] };
}
export function getContextMenuSubmenuKeyboardAction(
key: string,
hasSubmenu: boolean,
insideSubmenu: boolean
): ContextMenuSubmenuKeyboardAction | null {
if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) {
return "open";
}
if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) {
return "close";
}
return null;
}
function applyMenuItemSemantics(node: ReactNode): ReactNode {
return Children.map(node, (child) => {
if (!isValidElement(child)) {
return child;
}
const element = child as ReactElement<{
children?: ReactNode;
disabled?: boolean;
role?: string;
tabIndex?: number;
}>;
if (typeof element.type === "string" && element.type === "button") {
return cloneElement(element, { role: "menuitem", tabIndex: -1 });
}
if (element.props.children === undefined) {
return element;
}
return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) });
});
}
function getMenuItems(menu: HTMLElement | null): HTMLElement[] {
return Array.from(menu?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? []).filter((item) => {
if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") {
return false;
}
return item.getClientRects().length > 0;
});
}
function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] {
return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items"));
}
function getSubmenuParts(item: HTMLElement | null): {
container: HTMLElement;
trigger: HTMLElement;
items: HTMLElement;
} | null {
const container = item?.closest<HTMLElement>(".ctx-menu-sub") ?? null;
if (!container) {
return null;
}
const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']"));
const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items"));
if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) {
return null;
}
return { container, trigger, items };
}
function openSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
if (!parts) {
return;
}
parts.container.classList.add("is-keyboard-open");
parts.trigger.setAttribute("aria-expanded", "true");
positionSubmenu(parts);
getMenuItems(parts.items)[0]?.focus();
}
function positionSubmenu(parts: NonNullable<ReturnType<typeof getSubmenuParts>>): void {
const triggerRect = parts.trigger.getBoundingClientRect();
const submenuRect = parts.items.getBoundingClientRect();
const position = getContextSubmenuPosition(
triggerRect,
submenuRect,
{ width: window.innerWidth, height: window.innerHeight }
);
parts.items.style.position = "fixed";
parts.items.style.left = `${position.x}px`;
parts.items.style.top = `${position.y}px`;
}
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
if (!parts) {
return;
}
parts.container.classList.remove("is-keyboard-open");
parts.trigger.setAttribute("aria-expanded", "false");
parts.trigger.focus();
}
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function ContextMenu({
open,
x,
y,
onClose,
children,
ariaLabel = "Kontextmenü",
className = "",
ignoreOutsideRefs = []
}, forwardedRef): ReactElement | null {
const menuRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
const [position, setPosition] = useState({ x, y });
onCloseRef.current = onClose;
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
useImmediateEffect(() => {
if (!open || !menuRef.current) {
return;
}
if (!previousFocusRef.current) {
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
const rect = menuRef.current.getBoundingClientRect();
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
setPosition((current) => current.x === next.x && current.y === next.y ? current : next);
getTopLevelMenuItems(menuRef.current)[0]?.focus();
}, [open, x, y]);
useEffect(() => {
if (!open) {
return;
}
const onOutside = (event: MouseEvent): void => {
const target = event.target;
if (!(target instanceof Node) || menuRef.current?.contains(target)) {
return;
}
if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) {
return;
}
onCloseRef.current();
};
window.addEventListener("mousedown", onOutside);
window.addEventListener("contextmenu", onOutside);
return () => {
window.removeEventListener("mousedown", onOutside);
window.removeEventListener("contextmenu", onOutside);
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
restoreFocus(previousFocus);
};
}, [open]);
if (!open) {
return null;
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const submenu = getSubmenuParts(activeItem);
const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items"));
const hasSubmenu = submenu?.trigger === activeItem;
const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu);
if (submenuAction) {
event.preventDefault();
event.stopPropagation();
if (submenuAction === "open") {
openSubmenu(submenu);
} else {
closeSubmenu(submenu);
}
return;
}
const submenuItems = insideSubmenu ? activeItem?.closest<HTMLElement>(".ctx-menu-sub-items") ?? null : null;
const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current);
const currentIndex = items.findIndex((item) => item === document.activeElement);
const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true));
if (!action) {
return;
}
event.preventDefault();
event.stopPropagation();
if (action.type === "close") {
onClose();
return;
}
if (action.type === "activate") {
items[action.index]?.click();
return;
}
items[action.index]?.focus();
};
return (
<div
aria-label={ariaLabel}
className={["ctx-menu", "md-context-menu", className].filter(Boolean).join(" ")}
onClick={(event) => {
event.stopPropagation();
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
const submenu = getSubmenuParts(item);
if (submenu?.trigger === item) {
event.preventDefault();
openSubmenu(submenu);
}
}}
onKeyDown={onKeyDown}
onMouseOver={(event) => {
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
const submenu = getSubmenuParts(item);
if (submenu?.trigger === item) {
positionSubmenu(submenu);
}
}}
ref={menuRef}
role="menu"
style={{ left: position.x, top: position.y }}
>
{applyMenuItemSemantics(children)}
</div>
);
});
+104
View File
@@ -0,0 +1,104 @@
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
export interface DataTableProps extends HTMLAttributes<HTMLDivElement> {
label?: string;
}
export type DataTableSectionProps = HTMLAttributes<HTMLDivElement>;
export interface DataTableEmptyProps extends HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
illustration?: ReactNode;
}
export interface DataTableFooterProps extends HTMLAttributes<HTMLDivElement> {
pageSize: number;
rangeLabel: string;
paginationVisible: boolean;
}
export function DataTable({ label = "Datentabelle", className, children, ...props }: DataTableProps): ReactElement {
return (
<div
{...props}
aria-label={label}
className={["ui-data-table", className].filter(Boolean).join(" ")}
role="table"
>
{children}
</div>
);
}
export function DataTableHeader({ className, children, ...props }: DataTableSectionProps): ReactElement {
return (
<div
{...props}
className={["ui-data-table-header", className].filter(Boolean).join(" ")}
data-ui-region="table-header"
role="rowgroup"
>
{children}
</div>
);
}
export function DataTableBody({ className, children, ...props }: DataTableSectionProps): ReactElement {
return (
<div
{...props}
className={["ui-data-table-body", className].filter(Boolean).join(" ")}
data-ui-region="table-body"
role="rowgroup"
>
{children}
</div>
);
}
export function DataTableEmpty({
title,
description,
illustration,
className,
...props
}: DataTableEmptyProps): ReactElement {
return (
<div
{...props}
className={["ui-data-table-empty", className].filter(Boolean).join(" ")}
role="row"
>
<div className="ui-data-table-empty-cell" role="cell">
{illustration ? <div className="ui-data-table-empty-illustration">{illustration}</div> : null}
<strong className="ui-data-table-empty-title">{title}</strong>
{description ? <span className="ui-data-table-empty-description">{description}</span> : null}
</div>
</div>
);
}
export function DataTableFooter({
pageSize,
rangeLabel,
paginationVisible,
className,
...props
}: DataTableFooterProps): ReactElement | null {
if (!paginationVisible) {
return null;
}
return (
<div
{...props}
aria-label="Seitennavigation"
className={["ui-data-table-footer", className].filter(Boolean).join(" ")}
role="navigation"
>
<span className="ui-data-table-page-size">{pageSize} pro Seite</span>
<span className="ui-data-table-range" aria-live="polite">{rangeLabel}</span>
</div>
);
}
+271
View File
@@ -0,0 +1,271 @@
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
type KeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject
} from "react";
import { getConnectedFocusTarget, restoreFocus } from "./focus";
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
const focusableSelector = "button:not([disabled]), summary, [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
let activeDialogCount = 0;
let blockedShells: Map<HTMLElement, { inert: boolean; ariaHidden: string | null }> | null = null;
export type DialogSize = "default" | "account" | "update" | "wide";
export interface DialogProps {
open: boolean;
title: string;
description?: ReactNode;
size?: DialogSize;
danger?: boolean;
initialFocusRef?: RefObject<HTMLElement>;
restoreFocusTarget?: HTMLElement | null;
restoreFocusFallback?: () => HTMLElement | null;
onClose: () => void;
children: ReactNode;
actions: ReactNode;
closable?: boolean;
showCloseButton?: boolean;
className?: string;
backdropClassName?: string;
headerClassName?: string;
bodyClassName?: string;
actionsClassName?: string;
}
export type DialogKeyboardAction = { type: "focus"; index: number } | { type: "close" };
export function getDialogFocusTarget(shiftKey: boolean, currentIndex: number, itemCount: number): number | null {
if (itemCount <= 0) {
return null;
}
if (currentIndex < 0) {
return shiftKey ? itemCount - 1 : 0;
}
if (shiftKey && currentIndex === 0) {
return itemCount - 1;
}
if (!shiftKey && currentIndex === itemCount - 1) {
return 0;
}
return null;
}
export function getDialogInitialFocusTarget(
dialog: HTMLElement | null,
explicitTarget: HTMLElement | null,
activeTarget: HTMLElement | null = null
): HTMLElement | null {
if (explicitTarget) {
return explicitTarget;
}
if (activeTarget && dialog?.contains(activeTarget)) {
return activeTarget;
}
return dialog?.querySelector<HTMLElement>("[autofocus]") ?? dialog;
}
export function getDialogRestoreFocusTarget(
dialog: HTMLElement | null,
activeTarget: HTMLElement | null
): HTMLElement | null {
if (!activeTarget || dialog?.contains(activeTarget)) {
return null;
}
return activeTarget;
}
export function getConnectedDialogRestoreTarget(
previousTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null
): HTMLElement | null {
return getConnectedFocusTarget(previousTarget, fallbackTarget);
}
export function getDialogKeyboardAction(
key: string,
shiftKey: boolean,
currentIndex: number,
itemCount: number,
closable: boolean
): DialogKeyboardAction | null {
if (key === "Escape") {
return closable ? { type: "close" } : null;
}
if (key !== "Tab" || itemCount <= 0) {
return null;
}
const target = getDialogFocusTarget(shiftKey, currentIndex, itemCount);
return target === null ? null : { type: "focus", index: target };
}
function blockShell(): () => void {
if (activeDialogCount === 0) {
blockedShells = new Map();
document.querySelectorAll<HTMLElement>(".md-shell").forEach((shell) => {
blockedShells?.set(shell, {
inert: shell.hasAttribute("inert"),
ariaHidden: shell.getAttribute("aria-hidden")
});
shell.setAttribute("inert", "");
shell.setAttribute("aria-hidden", "true");
});
}
activeDialogCount += 1;
let released = false;
return () => {
if (released) {
return;
}
released = true;
activeDialogCount = Math.max(0, activeDialogCount - 1);
if (activeDialogCount !== 0 || !blockedShells) {
return;
}
for (const [shell, state] of blockedShells) {
if (!state.inert) {
shell.removeAttribute("inert");
}
if (state.ariaHidden === null) {
shell.removeAttribute("aria-hidden");
} else {
shell.setAttribute("aria-hidden", state.ariaHidden);
}
}
blockedShells = null;
};
}
export function Dialog({
open,
title,
description,
size = "default",
danger = false,
initialFocusRef,
restoreFocusTarget,
restoreFocusFallback,
onClose,
children,
actions,
closable = true,
showCloseButton = false,
className = "",
backdropClassName = "",
headerClassName = "",
bodyClassName = "",
actionsClassName = ""
}: DialogProps): ReactElement | null {
const dialogRef = useRef<HTMLDivElement>(null);
const backdropAttachedRef = useRef(false);
const previousFocusRef = useRef<HTMLElement | null>(null);
const restoreFocusFallbackRef = useRef<() => HTMLElement | null>(() => null);
const titleId = useId();
const descriptionId = useId();
restoreFocusFallbackRef.current = restoreFocusFallback ?? (() => null);
if (open && !previousFocusRef.current && restoreFocusTarget) {
previousFocusRef.current = getDialogRestoreFocusTarget(dialogRef.current, restoreFocusTarget);
}
const captureBackdropRef = useCallback((node: HTMLDivElement | null): void => {
if (node && !backdropAttachedRef.current && !previousFocusRef.current) {
const activeTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
previousFocusRef.current = getDialogRestoreFocusTarget(dialogRef.current, activeTarget);
}
backdropAttachedRef.current = Boolean(node);
}, []);
useImmediateEffect(() => {
if (!open) {
return;
}
const releaseShell = blockShell();
const activeTarget = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const focusTarget = getDialogInitialFocusTarget(dialogRef.current, initialFocusRef?.current ?? null, activeTarget);
focusTarget?.focus();
return () => {
releaseShell();
const previousFocus = previousFocusRef.current;
const fallbackFocus = restoreFocusFallbackRef.current();
previousFocusRef.current = null;
restoreFocus(previousFocus, fallbackFocus);
};
}, [initialFocusRef, open]);
if (!open) {
return null;
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
const focusable = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) ?? []);
const currentIndex = focusable.findIndex((element) => element === document.activeElement);
const action = getDialogKeyboardAction(event.key, event.shiftKey, currentIndex, focusable.length, closable);
if (!action) {
if (event.key === "Tab" && focusable.length === 0) {
event.preventDefault();
}
return;
}
event.preventDefault();
event.stopPropagation();
if (action.type === "close") {
onClose();
return;
}
focusable[action.index]?.focus();
};
const dialogClasses = [
"modal-card",
"md-dialog",
`md-dialog-size-${size}`,
danger ? "is-danger" : "",
className
].filter(Boolean).join(" ");
return (
<div
className={["modal-backdrop", "md-dialog-backdrop", backdropClassName].filter(Boolean).join(" ")}
onClick={() => {
if (closable) {
onClose();
}
}}
ref={captureBackdropRef}
>
<div
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
aria-modal="true"
className={dialogClasses}
onClick={(event) => event.stopPropagation()}
onKeyDown={onKeyDown}
ref={dialogRef}
role="dialog"
tabIndex={-1}
>
<div className={["md-dialog-header", headerClassName].filter(Boolean).join(" ")}>
<h2 id={titleId}>{title}</h2>
{showCloseButton && closable ? (
<button aria-label="Schließen" className="md-dialog-close" onClick={onClose} type="button">×</button>
) : null}
</div>
<div className={["md-dialog-body", bodyClassName].filter(Boolean).join(" ")}>
{description ? <p className="md-dialog-description" id={descriptionId}>{description}</p> : null}
{children}
</div>
{actions ? (
<div className={["modal-actions", "md-dialog-actions", actionsClassName].filter(Boolean).join(" ")}>{actions}</div>
) : null}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { ReactElement, SVGProps } from "react";
export const ICON_NAMES = [
"download",
"collector",
"settings",
"history",
"statistics",
"add",
"search",
"play",
"pause",
"stop",
"arrow-up",
"arrow-down",
"refresh",
"check",
"edit",
"trash",
"filter",
"folder",
"info",
"more",
"chevron-left",
"chevron-right",
"chevron-down",
"close",
"menu",
"help",
"backup",
"update",
"account"
] as const;
export type IconName = (typeof ICON_NAMES)[number];
export interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
label?: string;
size?: number;
}
function IconDrawing({ name }: { name: IconName }): ReactElement {
switch (name) {
case "download":
return <><path d="M12 3v12" /><path d="m7 10 5 5 5-5" /><path d="M5 21h14" /></>;
case "collector":
return <><path d="M10 13a5 5 0 0 0 7.1.1l2-2a5 5 0 0 0-7.1-7.1l-1.1 1.1" /><path d="M14 11a5 5 0 0 0-7.1-.1l-2 2a5 5 0 0 0 7.1 7.1l1.1-1.1" /></>;
case "settings":
return <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>;
case "history":
return <><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 3v5h5" /><path d="M12 7v5l3 2" /></>;
case "statistics":
return <><path d="M4 20V10" /><path d="M10 20V4" /><path d="M16 20v-7" /><path d="M22 20V7" /></>;
case "add":
return <><path d="M12 5v14" /><path d="M5 12h14" /></>;
case "search":
return <><circle cx="11" cy="11" r="7" /><path d="m20 20-4-4" /></>;
case "play":
return <path d="m8 5 11 7-11 7Z" />;
case "pause":
return <><path d="M9 5v14" /><path d="M15 5v14" /></>;
case "stop":
return <rect x="6" y="6" width="12" height="12" rx="1" />;
case "arrow-up":
return <><path d="m6 10 6-6 6 6" /><path d="M12 4v16" /></>;
case "arrow-down":
return <><path d="m6 14 6 6 6-6" /><path d="M12 20V4" /></>;
case "refresh":
return <><path d="M20 7v5h-5" /><path d="M4 17v-5h5" /><path d="M6.1 9a7 7 0 0 1 11.5-2L20 9" /><path d="m4 15 2.4 2a7 7 0 0 0 11.5-2" /></>;
case "check":
return <path d="m5 12 4 4L19 6" />;
case "edit":
return <><path d="M4 20h4L19 9l-4-4L4 16v4Z" /><path d="m13 7 4 4" /></>;
case "trash":
return <><path d="M4 7h16" /><path d="M9 7V4h6v3" /><path d="m6 7 1 14h10l1-14" /><path d="M10 11v6" /><path d="M14 11v6" /></>;
case "filter":
return <path d="M4 5h16l-6 7v6l-4 2v-8Z" />;
case "folder":
return <path d="M3 6h7l2 2h9v11H3Z" />;
case "info":
return <><circle cx="12" cy="12" r="9" /><path d="M12 11v6" /><path d="M12 7h.01" /></>;
case "more":
return <><circle cx="5" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" /><circle cx="19" cy="12" r="1" fill="currentColor" stroke="none" /></>;
case "chevron-left":
return <path d="m15 18-6-6 6-6" />;
case "chevron-right":
return <path d="m9 18 6-6-6-6" />;
case "chevron-down":
return <path d="m6 9 6 6 6-6" />;
case "close":
return <><path d="m6 6 12 12" /><path d="M18 6 6 18" /></>;
case "menu":
return <><path d="M4 6h16" /><path d="M4 12h16" /><path d="M4 18h16" /></>;
case "help":
return <><circle cx="12" cy="12" r="9" /><path d="M9.8 9a2.4 2.4 0 1 1 3.7 2c-1 .6-1.5 1.1-1.5 2" /><path d="M12 17h.01" /></>;
case "backup":
return <><path d="M6 4h10l3 3v13H6Z" /><path d="M9 4v6h6V4" /><path d="M9 20v-6h6v6" /></>;
case "update":
return <><path d="M12 21a9 9 0 1 0-8.5-6" /><path d="M3 15v6h6" /><path d="M12 7v5l3 2" /></>;
case "account":
return <><circle cx="12" cy="8" r="4" /><path d="M4 21a8 8 0 0 1 16 0" /></>;
}
}
export function Icon({ name, label, size = 18, className, ...props }: IconProps): ReactElement {
const labelled = typeof label === "string" && label.trim().length > 0;
return (
<svg
{...props}
aria-hidden={labelled ? undefined : true}
aria-label={labelled ? label : undefined}
className={["ui-icon", className].filter(Boolean).join(" ")}
fill="none"
focusable="false"
height={size}
role={labelled ? "img" : undefined}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.75"
viewBox="0 0 24 24"
width={size}
>
<IconDrawing name={name} />
</svg>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { ReactElement } from "react";
export interface ToastProps {
message: string;
}
export function Toast({ message }: ToastProps): ReactElement | null {
if (!message) {
return null;
}
return <div aria-live="polite" className="toast md-toast" role="status">{message}</div>;
}
+56
View File
@@ -0,0 +1,56 @@
import type { ChangeEventHandler, HTMLAttributes, InputHTMLAttributes, ReactElement } from "react";
import { Icon } from "./Icon";
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
label: string;
}
export interface ToolbarGroupProps extends HTMLAttributes<HTMLDivElement> {
label: string;
}
export interface ToolbarSearchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "aria-label"> {
label: string;
onChange: ChangeEventHandler<HTMLInputElement>;
}
export function Toolbar({ label, className, children, ...props }: ToolbarProps): ReactElement {
return (
<div
{...props}
aria-label={label}
className={["ui-toolbar", className].filter(Boolean).join(" ")}
role="toolbar"
>
{children}
</div>
);
}
export function ToolbarGroup({ label, className, children, ...props }: ToolbarGroupProps): ReactElement {
return (
<div
{...props}
aria-label={label}
className={["ui-toolbar-group", className].filter(Boolean).join(" ")}
role="group"
>
{children}
</div>
);
}
export function ToolbarSearch({ label, className, placeholder, ...props }: ToolbarSearchProps): ReactElement {
return (
<label className="ui-toolbar-search">
<Icon className="ui-toolbar-search-icon" name="search" size={16} />
<input
{...props}
aria-label={label}
className={["ui-input", "ui-toolbar-search-input", className].filter(Boolean).join(" ")}
placeholder={placeholder ?? label}
type="search"
/>
</label>
);
}
+22
View File
@@ -0,0 +1,22 @@
export type FocusRestoreScheduler = (callback: () => void) => void;
export function getConnectedFocusTarget(
preferredTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null = null
): HTMLElement | null {
if (preferredTarget?.isConnected) {
return preferredTarget;
}
return fallbackTarget?.isConnected ? fallbackTarget : null;
}
export function restoreFocus(
preferredTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null = null,
schedule: FocusRestoreScheduler = queueMicrotask
): void {
schedule(() => {
getConnectedFocusTarget(preferredTarget, fallbackTarget)?.focus();
});
}
+79
View File
@@ -0,0 +1,79 @@
export type UiTheme = "dark" | "light";
export const UI_THEME_VARIABLES = [
"--ui-canvas",
"--ui-surface",
"--ui-input",
"--ui-table-header",
"--ui-active",
"--ui-hover",
"--ui-tooltip",
"--ui-border",
"--ui-text",
"--ui-text-secondary",
"--ui-text-muted",
"--ui-primary",
"--ui-primary-hover",
"--ui-accent",
"--ui-warning",
"--ui-danger",
"--ui-modal-secondary",
"--ui-overlay"
] as const;
export type UiThemeVariable = (typeof UI_THEME_VARIABLES)[number];
export const UI_FOCUS_RING_VARIABLE: UiThemeVariable = "--ui-accent";
type UiThemeVariables = Readonly<Record<UiThemeVariable, string>>;
const darkThemeVariables: UiThemeVariables = Object.freeze({
"--ui-canvas": "#0F0F0F",
"--ui-surface": "#232323",
"--ui-input": "#2B2B2B",
"--ui-table-header": "#313131",
"--ui-active": "#333436",
"--ui-hover": "#373535",
"--ui-tooltip": "#4F4D4D",
"--ui-border": "#3D3D3D",
"--ui-text": "#FFFFFF",
"--ui-text-secondary": "#EAEDF3",
"--ui-text-muted": "#919191",
"--ui-primary": "#BAD0FC",
"--ui-primary-hover": "#8AA5DC",
"--ui-accent": "#3886FF",
"--ui-warning": "#F1C786",
"--ui-danger": "#F06464",
"--ui-modal-secondary": "#35383D",
"--ui-overlay": "rgba(0, 0, 0, 0.60)"
});
const lightThemeVariables: UiThemeVariables = Object.freeze({
"--ui-canvas": "#F3F4F6",
"--ui-surface": "#FFFFFF",
"--ui-input": "#F7F8FA",
"--ui-table-header": "#E7E9ED",
"--ui-active": "#DEE6F5",
"--ui-hover": "#E8ECF3",
"--ui-tooltip": "#35383D",
"--ui-border": "#D0D4DB",
"--ui-text": "#181A1F",
"--ui-text-secondary": "#343842",
"--ui-text-muted": "#667085",
"--ui-primary": "#A9C2F3",
"--ui-primary-hover": "#8AA5DC",
"--ui-accent": "#256FDB",
"--ui-warning": "#E8B85D",
"--ui-danger": "#D94747",
"--ui-modal-secondary": "#E7E9ED",
"--ui-overlay": "rgba(0, 0, 0, 0.45)"
});
const themes: Readonly<Record<UiTheme, UiThemeVariables>> = Object.freeze({
dark: darkThemeVariables,
light: lightThemeVariables
});
export function getThemeVariables(theme: UiTheme): Readonly<Record<string, string>> {
return themes[theme];
}
@@ -0,0 +1,208 @@
import type { ChangeEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import type { CollectorViewModel } from "./collector-model";
import "./collector.css";
export interface CollectorViewActions {
onTabSelect: (tabId: string) => void;
onTabAdd: () => void;
onTabRemove: (tabId: string) => void;
onOpenInput: () => void;
onImportDlc: () => void;
onImportFile: () => void;
onExportQueue: () => void;
onSubmit: () => void;
onQueryChange: (value: string) => void;
onSelectionChange: (rowId: string) => void;
onRemoveSelected: () => void;
}
export type CollectorViewRegion = "all" | "sidebar" | "toolbar" | "content";
export interface CollectorViewProps {
model: CollectorViewModel;
actions: CollectorViewActions;
region?: CollectorViewRegion;
}
export interface CollectorInputDialogProps {
open: boolean;
tabName: string;
value: string;
onChange: (value: string) => void;
onClose: () => void;
onCommit: () => void;
}
export function CollectorSidebar({ model, actions }: CollectorViewProps): ReactElement {
return (
<div aria-label="Sammlungen" className="collector-sidebar" data-visual-region="collector-sidebar">
<div className="collector-sidebar-heading">
<strong>Sammlungen</strong>
<span>{model.tabs.length}</span>
</div>
<div className="collector-sidebar-list">
{model.tabs.map((tab) => (
<div className={`collector-sidebar-item${tab.id === model.activeTabId ? " is-active" : ""}`} key={tab.id}>
<button
aria-current={tab.id === model.activeTabId ? "page" : undefined}
className="collector-sidebar-select"
onClick={() => actions.onTabSelect(tab.id)}
type="button"
>
<span>{tab.name}</span>
<span className="collector-sidebar-count">{tab.linkCount}</span>
</button>
{model.tabs.length > 1 ? (
<button
aria-label={`${tab.name} entfernen`}
className="collector-sidebar-remove"
onClick={() => actions.onTabRemove(tab.id)}
type="button"
>×</button>
) : null}
</div>
))}
</div>
<button className="collector-sidebar-add" onClick={actions.onTabAdd} type="button">Neue Sammlung</button>
</div>
);
}
export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactElement {
return (
<Toolbar className="collector-toolbar" data-visual-region="collector-toolbar" label="Linksammler-Aktionen">
<ToolbarGroup label="Links erfassen">
<button className="collector-action collector-action-primary" disabled={model.busy} onClick={actions.onOpenInput} type="button">Links hinzufügen</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportDlc} type="button">DLC importieren</button>
<button className="collector-action" disabled={model.busy} onClick={actions.onImportFile} type="button">Datei importieren</button>
</ToolbarGroup>
<ToolbarGroup label="Sammlung verarbeiten">
<button className="collector-action" disabled={model.busy} onClick={actions.onExportQueue} type="button">Queue exportieren</button>
<button className="collector-action" disabled={model.busy || model.tabs.length === 0} onClick={actions.onSubmit} type="button">An Downloads übergeben</button>
<button className="collector-action collector-action-danger" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button">Auswahl entfernen</button>
</ToolbarGroup>
<ToolbarSearch
label="Links durchsuchen"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Links durchsuchen"
value={model.query}
/>
</Toolbar>
);
}
export function CollectorContent({ model, actions }: CollectorViewProps): ReactElement {
const selected = new Set(model.selectedIds);
return (
<section className="collector-content" aria-label="Gesammelte Links">
<DataTable className="collector-table" label="Gesammelte Links">
<DataTableHeader className="collector-table-header">
<div className="collector-table-header-row" role="row">
<span aria-label="Auswahl" className="collector-column-select" role="columnheader" />
<span role="columnheader">Sammlung</span>
<span role="columnheader">URL oder Rohzeile</span>
<span role="columnheader">Zeile</span>
<span role="columnheader">Status</span>
</div>
</DataTableHeader>
<DataTableBody className="collector-table-body" data-visual-region="collector-table-body">
{model.busy ? (
<DataTableEmpty title="Links werden verarbeitet" description="Die laufende Aktion wird abgeschlossen." />
) : model.error ? (
<DataTableEmpty className="collector-table-error" title={model.error} description="Die lokale Sammlung bleibt unverändert." />
) : model.empty ? (
<DataTableEmpty
data-visual-region="collector-empty-state"
description={model.query ? "Passe die Suche an oder lösche den Filter." : "Füge Links hinzu oder importiere eine vorhandene Liste."}
title={model.query ? "Keine passenden Links" : "Noch keine Links"}
/>
) : (
model.rows.map((row) => (
<div className={`collector-row${selected.has(row.id) ? " is-selected" : ""}`} key={row.id} role="row">
<span className="collector-column-select" role="cell">
<input
aria-label="Link auswählen"
checked={selected.has(row.id)}
onChange={() => actions.onSelectionChange(row.id)}
type="checkbox"
/>
</span>
<span className="collector-row-source" role="cell">{row.tabName}</span>
<span className="collector-row-value" role="cell" title={row.value}>{row.value}</span>
<span className="collector-row-line" role="cell">{row.lineNumber}</span>
<span className="collector-row-status" role="cell">Lokal</span>
</div>
))
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function CollectorView({ model, actions, region = "all" }: CollectorViewProps): ReactElement {
if (region === "sidebar") {
return <CollectorSidebar actions={actions} model={model} />;
}
if (region === "toolbar") {
return <CollectorToolbar actions={actions} model={model} />;
}
if (region === "content") {
return <CollectorContent actions={actions} model={model} />;
}
return (
<div className="collector-view">
<CollectorSidebar actions={actions} model={model} />
<div className="collector-view-main">
<CollectorToolbar actions={actions} model={model} />
<CollectorContent actions={actions} model={model} />
</div>
</div>
);
}
export function CollectorInputDialog({
open,
tabName,
value,
onChange,
onClose,
onCommit
}: CollectorInputDialogProps): ReactElement | null {
return (
<Dialog
actions={(
<>
<button className="collector-dialog-secondary" onClick={onClose} type="button">Abbrechen</button>
<button className="collector-dialog-primary" onClick={onCommit} type="button">Übernehmen</button>
</>
)}
description={`Links für ${tabName} lokal erfassen.`}
onClose={onClose}
open={open}
size="wide"
title="Links hinzufügen"
>
<label className="collector-input-label">
<span>Links</span>
<textarea
aria-label="Links"
autoFocus
className="collector-input"
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onChange(event.target.value)}
placeholder="Eine URL oder Rohzeile pro Zeile"
rows={12}
value={value}
/>
</label>
</Dialog>
);
}
@@ -0,0 +1,88 @@
export interface CollectorSourceTab {
id: string;
name: string;
text: string;
}
export interface CollectorTabSummary {
id: string;
name: string;
linkCount: number;
}
export interface CollectorRow {
id: string;
tabId: string;
tabName: string;
originalLineIndex: number;
lineNumber: number;
value: string;
linkCount: number;
}
export interface CollectorViewModel {
tabs: CollectorTabSummary[];
activeTabId: string;
rows: CollectorRow[];
busy: boolean;
query: string;
selectedIds: string[];
empty: boolean;
error: string;
}
function nonEmptyLines(tab: CollectorSourceTab): Array<{ originalLineIndex: number; value: string }> {
return tab.text
.split(/\r?\n/)
.map((value, originalLineIndex) => ({ originalLineIndex, value: value.trim() }))
.filter((line) => line.value.length > 0);
}
export function buildCollectorRows(
tabs: CollectorSourceTab[],
activeTabId: string = tabs[0]?.id ?? "",
query = ""
): CollectorRow[] {
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
if (!activeTab) {
return [];
}
const lines = nonEmptyLines(activeTab);
const normalizedQuery = query.trim().toLocaleLowerCase("de");
return lines
.filter((line) => !normalizedQuery || line.value.toLocaleLowerCase("de").includes(normalizedQuery))
.map((line) => ({
id: `${activeTab.id}:${line.originalLineIndex}`,
tabId: activeTab.id,
tabName: activeTab.name,
originalLineIndex: line.originalLineIndex,
lineNumber: line.originalLineIndex + 1,
value: line.value,
linkCount: lines.length
}));
}
export function buildCollectorViewModel(
tabs: CollectorSourceTab[],
activeTabId: string,
query: string,
busy: boolean,
selectedIds: string[],
error = ""
): CollectorViewModel {
const rows = buildCollectorRows(tabs, activeTabId, query);
return {
tabs: tabs.map((tab) => ({
id: tab.id,
name: tab.name,
linkCount: nonEmptyLines(tab).length
})),
activeTabId,
rows,
busy,
query,
selectedIds,
empty: rows.length === 0,
error
};
}
+309
View File
@@ -0,0 +1,309 @@
.collector-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.collector-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
}
.collector-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px;
}
.collector-sidebar-heading {
align-items: center;
color: var(--ui-text-secondary);
display: flex;
font-size: 13px;
justify-content: space-between;
min-height: 28px;
}
.collector-sidebar-heading span,
.collector-sidebar-count {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-sidebar-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-height: 0;
overflow-y: auto;
}
.collector-sidebar-item {
align-items: stretch;
border: 1px solid transparent;
border-radius: 6px;
display: flex;
min-height: 36px;
}
.collector-sidebar-item:hover {
background: var(--ui-hover);
}
.collector-sidebar-item.is-active {
background: var(--ui-active);
border-color: var(--ui-border);
}
.collector-sidebar-select,
.collector-sidebar-remove,
.collector-sidebar-add {
background: transparent;
border: 0;
color: var(--ui-text-secondary);
font: inherit;
}
.collector-sidebar-select {
align-items: center;
display: flex;
flex: 1;
gap: 8px;
justify-content: space-between;
min-width: 0;
padding: 0 8px;
text-align: left;
}
.collector-sidebar-select span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-sidebar-remove {
border-left: 1px solid var(--ui-border);
min-width: 32px;
}
.collector-sidebar-add {
border: 1px solid var(--ui-border);
border-radius: 6px;
min-height: 36px;
padding: 0 10px;
text-align: left;
}
.collector-sidebar-add:hover,
.collector-sidebar-remove:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-toolbar {
min-width: 0;
width: 100%;
}
.collector-action {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
height: 36px;
padding: 0 12px;
white-space: nowrap;
}
.collector-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.collector-action-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-action-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: #181A1F;
}
.collector-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.collector-action:disabled {
cursor: default;
opacity: 0.45;
}
.collector-content {
display: flex;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.collector-table {
height: 100%;
}
.collector-table-header-row,
.collector-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(140px, 0.8fr) minmax(320px, 3fr) 90px 100px;
min-width: 760px;
}
.collector-table-header {
height: 41px;
}
.collector-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.collector-table-header-row > span,
.collector-row > span {
min-width: 0;
padding: 0 12px;
}
.collector-table-body {
overflow: auto;
}
.collector-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.collector-row:hover {
background: var(--ui-hover);
}
.collector-row.is-selected {
background: var(--ui-active);
}
.collector-column-select {
display: grid;
place-items: center;
}
.collector-row-source {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.collector-row-value {
color: var(--ui-text);
overflow: hidden;
text-overflow: ellipsis;
user-select: text;
white-space: nowrap;
}
.collector-row-line,
.collector-row-status {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.collector-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.collector-input-label {
display: grid;
gap: 8px;
}
.collector-input-label > span {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
}
.collector-input {
background: var(--ui-input);
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text);
font: inherit;
line-height: 1.5;
min-height: 240px;
padding: 12px;
resize: vertical;
user-select: text;
width: 100%;
}
.collector-dialog-primary,
.collector-dialog-secondary {
border: 1px solid var(--ui-border);
border-radius: 6px;
font: inherit;
height: 36px;
padding: 0 14px;
}
.collector-dialog-primary {
background: var(--ui-primary);
border-color: var(--ui-primary);
color: #181A1F;
}
.collector-dialog-secondary {
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
@media (max-width: 1366px) {
.collector-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.collector-sidebar {
overflow: hidden;
}
.collector-action {
padding: 0 9px;
}
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(120px, 0.7fr) minmax(280px, 2.4fr) 70px 86px;
min-width: 660px;
}
}
@media (max-width: 1120px) {
.collector-table-header-row,
.collector-row {
grid-template-columns: 44px minmax(108px, 0.7fr) minmax(250px, 2.2fr) 66px 82px;
min-width: 610px;
}
}
@@ -0,0 +1,375 @@
import { memo, type DragEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
import type { DownloadItem } from "../../../shared/types";
import {
compactProviderLabels,
extractHoster,
formatAudioStripSummary,
formatDateTime,
formatSpeedMbps,
humanSize,
providerLabels
} from "../../download-format";
import type { DownloadPackageRow } from "./downloads-model";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
export const downloadColumnDefinitions: Record<string, { label: string; width: string; sortable?: DownloadSortColumn }> = {
name: { label: "Name", width: "minmax(0, 0.92fr)", sortable: "name" },
size: { label: "Geladen / Größe", width: "160px", sortable: "size" },
progress: { label: "Fortschritt", width: "80px", sortable: "progress" },
hoster: { label: "Hoster", width: "110px", sortable: "hoster" },
account: { label: "Service", width: "132px" },
prio: { label: "Priorität", width: "70px" },
status: { label: "Status", width: "160px" },
speed: { label: "Geschwindigkeit", width: "90px" },
added: { label: "Hinzugefügt am", width: "155px" }
};
export interface DownloadsTableActions {
onSetVisibleSelection: (ids: string[], selected: boolean) => void;
onToggleSelection: (id: string, ctrlKey: boolean, shiftKey: boolean) => void;
onSelectionMouseDown: (id: string, event: ReactMouseEvent) => void;
onSelectionMouseEnter: (id: string) => void;
onTogglePackage: (packageId: string) => void;
onTogglePackageCollapse: (packageId: string) => void;
onStartPackageRename: (packageId: string, packageName: string) => void;
onPackageRenameChange: (name: string) => void;
onCommitPackageRename: (packageId: string, value: string) => void;
onCancelPackageRename: (packageId: string) => void;
onCancelPackage: (packageId: string) => void;
onMovePackageUp: (packageId: string) => void;
onMovePackageDown: (packageId: string) => void;
onRemoveItem: (itemId: string) => void;
onOpenContextMenu: (id: string, x: number, y: number, packageId?: string) => void;
onColumnDragStart: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragOver: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragLeave: () => void;
onColumnDrop: (column: string, event: DragEvent<HTMLDivElement>) => void;
onColumnDragEnd: () => void;
onColumnContextMenu: (column: string, x: number, y: number) => void;
onSortColumn: (column: DownloadSortColumn) => void;
}
function displayedStatus(item: DownloadItem, sessionRunning: boolean): string {
const value = item.fullStatus.trim();
if (value === "Wartet") return "";
if (sessionRunning) return value;
if (item.status !== "queued" && item.status !== "reconnect_wait") return value;
if (value === "Paket gestoppt") return value;
if (/^Entpacken\b/i.test(value) || /^Entpackt\b/i.test(value) || /^Entpack-Fehler\b/i.test(value) || /^Fertig\b/i.test(value)) return value;
return "";
}
function progress(value: number): number {
return Math.max(0, Math.min(100, Math.round(value || 0)));
}
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
const displayStatus = displayedStatus(item, sessionRunning);
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
const error = item.lastError.trim();
const statusTitle = displayStatus
? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}`
: error;
if (column === "name") {
return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${item.onlineStatus ?? "unknown"}`} />{item.fileName}</span>;
}
if (column === "size") {
const total = item.totalBytes || item.downloadedBytes || 0;
const value = total > 0 ? progress((item.downloadedBytes / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(item.downloadedBytes)} / {humanSize(total)}</b></span> : null}</span>;
}
if (column === "progress") {
const value = progress(item.progressPercent);
return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{value}%</b></span></span>;
}
if (column === "hoster") {
const hoster = extractHoster(item.url);
return <span className="downloads-cell" title={hoster}>{hoster}</span>;
}
if (column === "account") return <span className="downloads-cell">{item.providerLabel || (item.provider ? providerLabels[item.provider] : "")}</span>;
if (column === "prio") return <span className="downloads-cell" />;
if (column === "status") return <span className="downloads-cell" title={statusTitle}>{displayStatus}</span>;
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
if (column === "added") return <span className="downloads-cell">{formatDateTime(item.createdAt)}</span>;
return null;
}
export interface ItemRowProps {
item: DownloadItem;
selected: boolean;
sessionRunning?: boolean;
columnOrder: readonly string[];
gridTemplate: string;
actions: DownloadsTableActions;
}
export function ItemRowContent({ item, selected, sessionRunning = true, columnOrder, gridTemplate, actions }: ItemRowProps): ReactElement {
return (
<div
className={`downloads-item-row${selected ? " is-selected" : ""}`}
data-download-row-id={item.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
onClick={(event) => {
event.stopPropagation();
actions.onToggleSelection(item.id, event.ctrlKey || event.metaKey, event.shiftKey);
}}
onMouseDown={(event) => {
event.stopPropagation();
actions.onSelectionMouseDown(item.id, event);
}}
onMouseEnter={() => actions.onSelectionMouseEnter(item.id)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId);
}}
>
<span className="downloads-selection-cell" role="cell"><input aria-label={`${item.fileName} auswählen`} checked={selected} onChange={() => actions.onToggleSelection(item.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{itemCell(item, column, sessionRunning)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${item.fileName} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(item.id, event.clientX, event.clientY, item.packageId); }} type="button"></button></span>
</div>
);
}
export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps): boolean {
const a = previous.item;
const b = next.item;
return a.id === b.id
&& a.updatedAt === b.updatedAt
&& a.status === b.status
&& a.fileName === b.fileName
&& a.url === b.url
&& a.provider === b.provider
&& a.providerLabel === b.providerLabel
&& a.providerAccountId === b.providerAccountId
&& a.providerAccountLabel === b.providerAccountLabel
&& a.fullStatus === b.fullStatus
&& a.lastError === b.lastError
&& a.onlineStatus === b.onlineStatus
&& a.progressPercent === b.progressPercent
&& a.speedBps === b.speedBps
&& a.downloadedBytes === b.downloadedBytes
&& a.totalBytes === b.totalBytes
&& a.retries === b.retries
&& a.createdAt === b.createdAt
&& previous.selected === next.selected
&& previous.sessionRunning === next.sessionRunning
&& previous.columnOrder === next.columnOrder
&& previous.gridTemplate === next.gridTemplate
&& previous.actions === next.actions;
}
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
let done = 0;
let failed = 0;
let cancelled = 0;
let extracted = 0;
let extracting = false;
let activeProgress = 0;
let extractingProgress = 0;
for (const item of row.items) {
if (item.status === "completed") done += 1;
else if (item.status === "failed") failed += 1;
else if (item.status === "cancelled") cancelled += 1;
const fullStatus = item.fullStatus || "";
if (fullStatus.startsWith("Entpackt")) {
extracted += 1;
} else if (fullStatus.startsWith("Entpacken")) {
extracting = true;
const match = fullStatus.match(/^Entpacken\s+(\d+)%/);
if (match) extractingProgress += Number(match[1]) / 100;
}
if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
activeProgress += (item.progressPercent || 0) / 100;
}
}
const total = Math.max(1, row.items.length);
const allDownloaded = done + failed + cancelled >= total;
const allExtracted = extracted >= total;
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
return { done, failed, cancelled, total, value };
}
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
const entry = row.package;
const stats = packageProgress(row);
if (column === "name") {
return (
<span className="downloads-cell downloads-name-cell">
<button aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : ""}</button>
<input aria-label={`${entry.name} aktivieren`} checked={entry.enabled} onChange={() => actions.onTogglePackage(entry.id)} onClick={(event) => event.stopPropagation()} type="checkbox" />
{editing
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
finishRename(editingName);
event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
actions.onCancelPackageRename(entry.id);
}
}} />
: <strong className="downloads-copyable" onDoubleClick={(event) => { event.stopPropagation(); actions.onStartPackageRename(entry.id, entry.name); }} title={entry.name}>{entry.name}</strong>}
</span>
);
}
if (column === "size") {
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <span className="downloads-meter"><span style={{ width: `${value}%` }} /><b>{humanSize(downloaded)} / {humanSize(total)}</b></span> : null}</span>;
}
if (column === "progress") return <span className="downloads-cell downloads-progress-cell"><span className="downloads-meter"><span style={{ width: `${stats.value}%` }} /><b>{stats.value}%</b></span></span>;
if (column === "hoster") {
const value = [...new Set(row.items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
return <span className="downloads-cell" title={value}>{value}</span>;
}
if (column === "account") {
const value = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
return <span className="downloads-cell" title={value}>{value}</span>;
}
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
if (column === "status") {
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
return <span className="downloads-cell" title={audio?.tooltip}>{stats.done}/{stats.total}{stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}{stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}{entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}{audio ? ` · ${audio.text}` : ""}</span>;
}
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
return null;
}
export interface PackageCardProps {
row: DownloadPackageRow;
selectedIds: Set<string>;
selectedVersion: number;
editing: boolean;
editingName: string;
packageSpeedBps: number;
sessionRunning?: boolean;
columnOrder: readonly string[];
gridTemplate: string;
actions: DownloadsTableActions;
draggable?: boolean;
onDragStart?: (packageId: string) => void;
onDrop?: (packageId: string) => void;
onDragEnd?: () => void;
}
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions, draggable = true, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
const entry = row.package;
let renameFinished = false;
const finishRename = (value: string): void => {
if (renameFinished) return;
renameFinished = true;
actions.onCommitPackageRename(entry.id, value);
};
return (
<article
className={`package-card downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
data-download-package-id={entry.id}
draggable={draggable}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id);
}}
onDragStart={(event) => { event.stopPropagation(); onDragStart?.(entry.id); }}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop?.(entry.id); }}
onDragEnd={(event) => { event.stopPropagation(); onDragEnd?.(); }}
>
<div
className="downloads-package-row"
data-download-row-id={entry.id}
role="row"
style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (event.ctrlKey || event.metaKey || event.shiftKey) {
actions.onToggleSelection(entry.id, event.ctrlKey || event.metaKey, event.shiftKey);
return;
}
if (target.closest("button, input, select")) return;
actions.onTogglePackageCollapse(entry.id);
}}
onMouseDown={(event) => actions.onSelectionMouseDown(entry.id, event)}
onMouseEnter={() => actions.onSelectionMouseEnter(entry.id)}
>
<span className="downloads-selection-cell" role="cell"><input aria-label={`${entry.name} auswählen`} checked={selectedIds.has(entry.id)} onChange={() => actions.onToggleSelection(entry.id, true, false)} onClick={(event) => event.stopPropagation()} type="checkbox" /></span>
{columnOrder.map((column) => <span className="downloads-cell-slot" key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button"></button></span>
</div>
{!row.collapsed && row.items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
</article>
);
}
export function arePackageCardPropsEqual(previous: PackageCardProps, next: PackageCardProps): boolean {
const a = previous.row.package;
const b = next.row.package;
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions || previous.draggable !== next.draggable || previous.onDragStart !== next.onDragStart || previous.onDrop !== next.onDrop || previous.onDragEnd !== next.onDragEnd) return false;
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
for (const itemId of b.itemIds) {
if (previous.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) return false;
}
}
if (previous.row.items.length !== next.row.items.length) return false;
for (let index = 0; index < previous.row.items.length; index += 1) {
const oldItem = previous.row.items[index];
const newItem = next.row.items[index];
if (!oldItem || !newItem || !areItemRowPropsEqual({ actions: previous.actions, columnOrder: previous.columnOrder, gridTemplate: previous.gridTemplate, item: oldItem, selected: previous.selectedIds.has(oldItem.id), sessionRunning: previous.sessionRunning }, { actions: next.actions, columnOrder: next.columnOrder, gridTemplate: next.gridTemplate, item: newItem, selected: next.selectedIds.has(newItem.id), sessionRunning: next.sessionRunning })) return false;
}
return true;
}
export const PackageCard = memo(PackageCardContent, arePackageCardPropsEqual);
export interface DownloadsTableHeaderProps {
actions: DownloadsTableActions;
columnOrder: readonly string[];
gridTemplate: string;
sortColumn: DownloadSortColumn;
sortDirection: "asc" | "desc";
selectedCount: number;
visibleIds: string[];
}
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
return (
<div className="downloads-table-header" role="row" style={{ gridTemplateColumns: `36px ${gridTemplate} 44px` }}>
<span className="downloads-selection-cell" role="columnheader"><input aria-label="Alle sichtbaren Downloads auswählen" checked={visibleIds.length > 0 && selectedCount === visibleIds.length} onChange={(event) => actions.onSetVisibleSelection(visibleIds, event.target.checked)} type="checkbox" /></span>
{columnOrder.map((column) => {
const definition = downloadColumnDefinitions[column];
if (!definition) return null;
return (
<div
className="downloads-column-header"
draggable
key={column}
onContextMenu={(event) => { event.preventDefault(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
onDragEnd={actions.onColumnDragEnd}
onDragLeave={actions.onColumnDragLeave}
onDragOver={(event) => actions.onColumnDragOver(column, event)}
onDragStart={(event) => actions.onColumnDragStart(column, event)}
onDrop={(event) => actions.onColumnDrop(column, event)}
role="columnheader"
>
{definition.sortable
? <button onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
: definition.label}
</div>
);
})}
<span className="downloads-action-cell" role="columnheader">Aktion</span>
</div>
);
}
@@ -0,0 +1,197 @@
import type { ReactElement } from "react";
import type { DownloadPackageRow, DownloadsViewModelCore, DownloadDisplayMode, DownloadSidebarFilter } from "./downloads-model";
import {
DownloadsTableHeader,
ItemRow,
PackageCard,
type DownloadSortColumn,
type DownloadsTableActions
} from "./DownloadsTable";
import "./downloads.css";
export interface DownloadsStatusModel {
packages: number;
links: number;
session: string;
total: string;
hosters: number;
speed: string;
eta: string;
}
export interface DownloadsViewModel extends DownloadsViewModelCore {
running: boolean;
paused: boolean;
canStart: boolean;
canPause: boolean;
canStop: boolean;
actionBusy: boolean;
reconnectSeconds: number;
reconnectReason: string;
clipboardWatcher: boolean;
scheduleActive: boolean;
scheduleOpen: boolean;
scheduleTime: string;
scheduleLabel: string;
packageSpeedBps: Record<string, number>;
editingPackageId: string | null;
editingName: string;
columnOrder: readonly string[];
gridTemplate: string;
sortColumn?: DownloadSortColumn;
sortDirection?: "asc" | "desc";
status: DownloadsStatusModel;
}
export interface DownloadsViewActions extends DownloadsTableActions {
onDisplayModeChange: (mode: DownloadDisplayMode) => void;
onFilterChange: (filter: DownloadSidebarFilter) => void;
onProviderFilterChange: (provider: string) => void;
onQueryChange: (query: string) => void;
onAddLinks: () => void;
onStartDownloads: () => void;
onPauseDownloads: () => void;
onStopDownloads: () => void;
onToggleSchedule: () => void;
onScheduleTimeChange: (value: string) => void;
onActivateSchedule: () => void;
onCancelSchedule: () => void;
onMoveSelectionUp: () => void;
onMoveSelectionDown: () => void;
onRenameSelection: () => void;
onRemoveSelection: () => void;
onToggleClipboardWatcher: () => void;
onClearAll: () => void;
onToggleAllPackages: () => void;
onShowAllPackages: () => void;
onPackageDragStart: (packageId: string) => void;
onPackageDrop: (packageId: string) => void;
onPackageDragEnd: () => void;
}
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
{ id: "all", label: "Alle" },
{ id: "active", label: "Aktiv" },
{ id: "queued", label: "Wartend" },
{ id: "paused", label: "Pausiert" },
{ id: "completed", label: "Fertig" },
{ id: "failed", label: "Fehler" }
];
export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
<div className="downloads-mode-switch" role="group" aria-label="Downloadansicht">
<button className={model.displayMode === "packages" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("packages")} type="button">Pakete</button>
<button className={model.displayMode === "files" ? "is-active" : ""} onClick={() => actions.onDisplayModeChange("files")} type="button">Dateien</button>
</div>
<nav aria-label="Downloadfilter">
{filters.map((filter) => <button className={model.filter === filter.id ? "is-active" : ""} key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
</nav>
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
<div className="downloads-sidebar-actions">
<button onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
<button disabled={model.empty} onClick={actions.onClearAll} type="button">Liste leeren</button>
<label><input checked={model.clipboardWatcher} onChange={actions.onToggleClipboardWatcher} type="checkbox" />Zwischenablage überwachen</label>
</div>
</aside>
);
}
export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel }): ReactElement {
const entries = [
["Pakete", String(model.status.packages)],
["Links", String(model.status.links)],
["Sitzung", model.status.session],
["Gesamt", model.status.total],
["Hoster", String(model.status.hosters)],
["Geschwindigkeit", model.status.speed],
["ETA", model.status.eta]
];
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map(([label, value]) => <div key={label}><span>{label}</span><strong>{value}</strong></div>)}</section>;
}
export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
const hasSelection = model.actionableSelectedIds.length > 0;
const hasSelectedPackage = model.actionableSelectedPackageIds.length > 0;
const onePackage = model.actionableSelectedPackageIds.length === 1 && model.actionableSelectedIds.length === 1;
return (
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
<button className="ui-primary-button" onClick={actions.onAddLinks} type="button">Links hinzufügen</button>
<span className="downloads-toolbar-divider" />
<button disabled={model.actionBusy || (!model.canStart && !model.paused)} onClick={actions.onStartDownloads} type="button">Start</button>
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
{model.scheduleActive
? <span className="downloads-schedule-controls"><strong>Geplant: {model.scheduleLabel}</strong><button disabled={false} onClick={actions.onCancelSchedule} type="button">Abbrechen</button></span>
: <><button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button>{model.scheduleOpen ? <span className="downloads-schedule-controls"><input aria-label="Startzeit" onChange={(event) => actions.onScheduleTimeChange(event.target.value)} type="time" value={model.scheduleTime} /><button onClick={actions.onActivateSchedule} type="button">Planen</button></span> : null}</>}
<span className="downloads-toolbar-divider" />
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionUp} type="button">Nach oben</button>
<button disabled={!hasSelectedPackage} onClick={actions.onMoveSelectionDown} type="button">Nach unten</button>
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
</div>
);
}
function tableState(model: DownloadsViewModel): ReactElement | null {
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
return null;
}
function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement[] {
return model.packageRows.map((row: DownloadPackageRow) => (
<PackageCard
actions={actions}
columnOrder={model.columnOrder}
editing={model.editingPackageId === row.package.id}
editingName={model.editingName}
gridTemplate={model.gridTemplate}
key={row.package.id}
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
onDragEnd={actions.onPackageDragEnd}
onDragStart={actions.onPackageDragStart}
onDrop={actions.onPackageDrop}
row={row}
selectedIds={model.selectedIds}
selectedVersion={model.actionableSelectedIds.length}
sessionRunning={model.running}
/>
));
}
export function DownloadsContent({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<main className="downloads-content">
<div className="downloads-table" role="table" aria-label="Downloads">
<DownloadsTableHeader actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} selectedCount={model.actionableSelectedIds.length} sortColumn={model.sortColumn ?? "name"} sortDirection={model.sortDirection ?? "asc"} visibleIds={model.visibleRowIds} />
<div className="downloads-table-body" data-visual-region="downloads-table-body" role="rowgroup">
{tableState(model)}
{!model.empty && !model.filteredEmpty && model.displayMode === "packages" ? packageRows(model, actions) : null}
{!model.empty && !model.filteredEmpty && model.displayMode === "files" ? model.fileRows.map((item) => <ItemRow actions={actions} columnOrder={model.columnOrder} gridTemplate={model.gridTemplate} item={item} key={item.id} selected={model.selectedIds.has(item.id)} sessionRunning={model.running} />) : null}
</div>
</div>
</main>
);
}
export function DownloadsFooter({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<footer className="downloads-footer" data-visual-region="downloads-pagination">
<span>{model.paginationLabel}</span>
{model.limited ? <button onClick={actions.onShowAllPackages} type="button">Alle anzeigen</button> : null}
<span>{model.running ? model.paused ? "Pausiert" : "Download läuft" : "Bereit"}</span>
</footer>
);
}
export function DownloadsView({ actions, model }: { actions: DownloadsViewActions; model: DownloadsViewModel }): ReactElement {
return (
<div className="downloads-view">
<div className="downloads-side-column"><DownloadsSidebar actions={actions} model={model} /><DownloadsSidebarStatus model={model} /></div>
<div className="downloads-main-column"><DownloadsToolbar actions={actions} model={model} /><DownloadsContent actions={actions} model={model} /><DownloadsFooter actions={actions} model={model} /></div>
</div>
);
}
@@ -0,0 +1,186 @@
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
export type DownloadDisplayMode = "packages" | "files";
export type DownloadSidebarFilter = "all" | "active" | "queued" | "paused" | "completed" | "failed";
export interface DownloadsModelInput {
packageOrder: string[];
packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>;
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
query: string;
collapsedPackageIds: Iterable<string>;
selectedIds: Iterable<string>;
hideExtractedItems: boolean;
showAllPackages: boolean;
renderLimit: number;
}
export interface DownloadFilterCounts {
all: number;
active: number;
queued: number;
paused: number;
completed: number;
failed: number;
}
export interface DownloadPackageRow {
package: PackageEntry;
items: DownloadItem[];
collapsed: boolean;
}
export interface DownloadsViewModelCore {
displayMode: DownloadDisplayMode;
filter: DownloadSidebarFilter;
providerFilter: string;
providerOptions: Array<{ id: string; label: string }>;
query: string;
counts: DownloadFilterCounts;
packageRows: DownloadPackageRow[];
fileRows: DownloadItem[];
visibleItemIds: string[];
visibleRowIds: string[];
actionableSelectedIds: string[];
actionableSelectedPackageIds: string[];
selectedIds: Set<string>;
mainRowCount: number;
totalMainRowCount: number;
paginationLabel: string;
limited: boolean;
empty: boolean;
filteredEmpty: boolean;
}
const activeStatuses = new Set<DownloadStatus>(["downloading", "validating", "extracting", "integrity_check"]);
const queuedStatuses = new Set<DownloadStatus>(["queued", "reconnect_wait"]);
export function classifyDownloadStatus(status: DownloadStatus): DownloadSidebarFilter {
if (activeStatuses.has(status)) return "active";
if (queuedStatuses.has(status)) return "queued";
if (status === "paused" || status === "completed" || status === "failed") return status;
return "all";
}
export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): DownloadFilterCounts {
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
for (const item of items) {
counts.all += 1;
const category = classifyDownloadStatus(item.status);
if (category !== "all") counts[category] += 1;
}
return counts;
}
function isExtracted(item: DownloadItem): boolean {
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
}
function matchesQuery(value: string | undefined, query: string): boolean {
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
}
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
return filter === "all" || classifyDownloadStatus(item.status) === filter;
}
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
return providerFilter === "all" || item.provider === providerFilter;
}
function isActivePackage(row: DownloadPackageRow): boolean {
return row.items.some((entry) => classifyDownloadStatus(entry.status) === "active");
}
function paginationLabel(visible: number, total: number): string {
if (visible === 0 || total === 0) return "0 von 0";
return `1${visible} von ${total}`;
}
export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsViewModelCore {
const allPackages = input.packageOrder
.map((id) => input.packages[id])
.filter((entry): entry is PackageEntry => Boolean(entry));
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
const counts = buildDownloadSidebarCounts(allItems);
const providerMap = new Map<string, string>();
for (const entry of allItems) {
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
}
const query = input.query.trim().toLocaleLowerCase("de-DE");
const collapsed = new Set(input.collapsedPackageIds);
const selectedIds = new Set(input.selectedIds);
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
const items = entry.itemIds
.map((id) => input.items[id])
.filter((item): item is DownloadItem => Boolean(item))
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
const matchingItems = items.filter((item) => {
const itemMatchesQuery = query === ""
|| matchesQuery(item.fileName, query)
|| matchesQuery(item.targetPath, query)
|| matchesQuery(item.providerLabel, query)
|| matchesQuery(item.providerAccountLabel, query)
|| matchesQuery(item.fullStatus, query)
|| matchesQuery(item.lastError, query);
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
});
if (matchingItems.length === 0) return [];
const visibleItems = packageMatchesQuery && query !== ""
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
: matchingItems;
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
});
const totalPackageRows = packageRows.length;
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
if (!input.showAllPackages && input.renderLimit > 0 && packageRows.length > input.renderLimit) {
const activeRows = packageRows.filter(isActivePackage);
const inactiveRows = packageRows.filter((row) => !isActivePackage(row));
packageRows = [...activeRows, ...inactiveRows].slice(0, input.renderLimit);
}
const fileRows = input.displayMode === "files" ? allMatchingFileRows : [];
const displayedPackages = input.displayMode === "packages" ? packageRows : [];
const visibleItemIds = (input.displayMode === "files"
? fileRows
: displayedPackages.flatMap((row) => row.collapsed ? [] : row.items)).map((entry) => entry.id);
const visibleRowIds = input.displayMode === "files"
? visibleItemIds
: displayedPackages.flatMap((row) => row.collapsed ? [row.package.id] : [row.package.id, ...row.items.map((entry) => entry.id)]);
const visibleRowSet = new Set(visibleRowIds);
const actionableSelectedIds = [...selectedIds].filter((id) => visibleRowSet.has(id));
const visiblePackageSet = new Set(displayedPackages.map((row) => row.package.id));
const actionableSelectedPackageIds = actionableSelectedIds.filter((id) => visiblePackageSet.has(id));
const mainRowCount = input.displayMode === "files" ? fileRows.length : displayedPackages.length;
const totalMainRowCount = input.displayMode === "files"
? fileRows.length
: totalPackageRows;
return {
displayMode: input.displayMode,
filter: input.filter,
providerFilter: input.providerFilter,
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
query: input.query,
counts,
packageRows: displayedPackages,
fileRows,
visibleItemIds,
visibleRowIds,
actionableSelectedIds,
actionableSelectedPackageIds,
selectedIds,
mainRowCount,
totalMainRowCount,
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
limited: mainRowCount < totalMainRowCount,
empty: allItems.length === 0,
filteredEmpty: allItems.length > 0 && mainRowCount === 0
};
}
+423
View File
@@ -0,0 +1,423 @@
.downloads-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-height: 0;
height: 100%;
overflow: hidden;
background: var(--ui-canvas);
}
.downloads-side-column {
display: flex;
flex-direction: column;
min-height: 0;
border-right: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.downloads-sidebar,
.downloads-sidebar-status,
.downloads-toolbar,
.downloads-content,
.downloads-footer {
user-select: none;
}
.downloads-copyable,
.downloads-search-input,
.downloads-rename-input {
user-select: text;
}
.downloads-sidebar {
display: flex;
flex: 1;
flex-direction: column;
gap: 14px;
min-height: 0;
padding: 14px 12px;
}
.downloads-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
padding: 2px;
border: 1px solid var(--ui-border);
border-radius: 6px;
}
.downloads-mode-switch button,
.downloads-sidebar nav button,
.downloads-sidebar-actions button {
min-height: 36px;
border: 0;
border-radius: 4px;
color: var(--ui-text);
background: transparent;
text-align: left;
}
.downloads-mode-switch button {
text-align: center;
}
.downloads-mode-switch button.is-active,
.downloads-sidebar nav button.is-active {
color: #ffffff;
background: var(--ui-accent);
}
.downloads-sidebar nav {
display: grid;
gap: 3px;
}
.downloads-sidebar nav button {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px;
}
.downloads-provider-filter,
.downloads-sidebar-search {
display: grid;
gap: 5px;
color: var(--ui-text-muted);
font-size: 12px;
}
.downloads-provider-filter select,
.downloads-sidebar-search input,
.downloads-schedule-controls input,
.downloads-rename-input {
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 5px;
padding: 0 10px;
color: var(--ui-text);
background: var(--ui-input);
}
.downloads-sidebar-actions {
display: grid;
gap: 5px;
margin-top: auto;
}
.downloads-sidebar-actions label {
display: flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 0 8px;
}
.downloads-sidebar-status {
display: grid;
gap: 6px;
padding: 12px;
border-top: 1px solid var(--ui-border);
}
.downloads-sidebar-status div {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--ui-text-muted);
font-size: 12px;
}
.downloads-sidebar-status strong {
color: var(--ui-text);
font-weight: 600;
text-align: right;
}
.downloads-main-column {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.downloads-toolbar {
display: flex;
align-items: center;
gap: 6px;
min-height: 52px;
padding: 8px 10px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.downloads-toolbar button,
.downloads-footer button,
.downloads-action-cell button,
.downloads-collapse-button,
.downloads-column-header button {
min-height: 36px;
height: 36px;
border: 1px solid var(--ui-border);
border-radius: 5px;
padding: 0 10px;
color: var(--ui-text);
background: var(--ui-modal-secondary);
}
.downloads-toolbar button:disabled,
.downloads-footer button:disabled {
opacity: 0.42;
}
.downloads-toolbar-divider {
width: 1px;
align-self: stretch;
margin: 1px 2px;
background: var(--ui-border);
}
.downloads-schedule-controls {
display: flex;
align-items: center;
gap: 5px;
}
.downloads-schedule-controls input {
height: 36px;
}
.downloads-sidebar-search span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.downloads-content {
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
background: var(--ui-canvas);
}
.downloads-table {
width: 100%;
height: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: auto;
}
.downloads-table-header {
display: grid;
align-items: center;
height: 41px;
position: sticky;
top: 0;
z-index: 2;
min-width: max-content;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-muted);
background: var(--ui-table-header);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.downloads-table-body {
min-width: max-content;
overflow: visible;
}
.downloads-package-card {
min-width: max-content;
border: 0;
border-bottom: 1px solid var(--ui-border);
border-radius: 0;
padding: 0;
background: var(--ui-canvas);
}
.downloads-package-card.is-disabled {
opacity: 0.58;
}
.downloads-item-row,
.downloads-package-row {
display: grid;
align-items: center;
height: 48px;
min-width: max-content;
color: var(--ui-text);
background: var(--ui-canvas);
}
.downloads-item-row {
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
}
.downloads-item-row.is-selected,
.downloads-package-card.is-selected > .downloads-package-row {
background: var(--ui-active);
}
.downloads-cell-slot {
min-width: 0;
}
.downloads-cell,
.downloads-name-cell {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
padding: 0 9px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-name-cell strong {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-selection-cell,
.downloads-action-cell {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
}
.downloads-action-cell button,
.downloads-collapse-button {
width: 30px;
height: 30px;
min-height: 30px;
padding: 0;
}
.downloads-column-header {
min-width: 0;
padding: 0 9px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.downloads-column-header button {
width: 100%;
border: 0;
padding: 0;
background: transparent;
text-align: left;
text-transform: inherit;
}
.downloads-link-state {
flex: 0 0 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ui-text-muted);
}
.downloads-link-state.online {
background: var(--ui-primary);
}
.downloads-link-state.offline {
background: var(--ui-danger);
}
.downloads-link-state.checking {
background: var(--ui-warning);
}
.downloads-meter {
position: relative;
display: block;
width: 100%;
height: 24px;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 4px;
background: var(--ui-modal-secondary);
}
.downloads-meter > span {
position: absolute;
inset: 0 auto 0 0;
background: color-mix(in srgb, var(--ui-accent) 58%, transparent);
}
.downloads-meter > b {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 11px;
font-weight: 600;
}
.downloads-empty-state,
.downloads-table-message {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--ui-text-muted);
}
.downloads-empty-state > div,
.downloads-table-message > div {
display: grid;
gap: 4px;
text-align: center;
}
.downloads-empty-state strong,
.downloads-table-message strong {
color: var(--ui-text);
}
.downloads-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
height: 60px;
padding: 0 12px 0 60px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
background: var(--ui-surface);
font-size: 12px;
}
@media (max-width: 1366px) {
.downloads-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.downloads-side-column {
overflow: hidden;
}
}
@media (max-width: 1120px) {
.downloads-toolbar,
.downloads-footer {
overflow-x: auto;
}
}
+239
View File
@@ -0,0 +1,239 @@
import type { ChangeEvent, MouseEvent, ReactElement } from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableFooter,
DataTableHeader
} from "../../ui/DataTable";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../../ui/Toolbar";
import type { HistoryFilter, HistoryRow, HistoryViewModel } from "./history-model";
import "./history.css";
export interface HistoryViewActions {
onFilterChange: (filter: HistoryFilter) => void;
onQueryChange: (value: string) => void;
onToggleSelection: (entryId: string) => void;
onToggleSelectAll: (visibleIds: string[]) => void;
onToggleExpansion: (entryId: string) => void;
onRestore: (entryIds: string[]) => void;
onReveal: (entryId: string) => void;
onRemove: (entryIds: string[]) => void;
onClearSelection: () => void;
onClearHistory: () => void;
onContextMenu: (entryId: string, x: number, y: number) => void;
}
export interface HistoryViewProps {
model: HistoryViewModel;
actions: HistoryViewActions;
}
const filterItems: Array<{ id: HistoryFilter; label: string }> = [
{ id: "all", label: "Alle Einträge" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Letzte 7 Tage" },
{ id: "older", label: "Älter" },
{ id: "completed", label: "Fertig" },
{ id: "deleted", label: "Gelöscht" },
{ id: "failed", label: "Fehlgeschlagen" }
];
function HistoryRowDetails({ row }: { row: HistoryRow }): ReactElement {
return (
<div className="history-detail-row" role="row">
<div className="history-detail-cell" role="cell">
<dl className="history-details-grid">
<div><dt>Provider</dt><dd>{row.providerLabel}</dd></div>
<div><dt>Dateien</dt><dd>{row.fileCount}</dd></div>
<div><dt>Dauer</dt><dd>{row.durationLabel}</dd></div>
<div><dt>Durchschnitt</dt><dd>{row.averageSpeedLabel}</dd></div>
<div className="history-detail-wide"><dt>Zielordner</dt><dd className="history-copyable">{row.outputDir || "—"}</dd></div>
<div className="history-detail-wide"><dt>URLs</dt><dd className="history-copyable">{row.urls?.length ? row.urls.join("\n") : "—"}</dd></div>
</dl>
</div>
</div>
);
}
export function HistorySidebar({ model, actions }: HistoryViewProps): ReactElement {
return (
<div aria-label="Verlaufsfilter" className="history-sidebar" data-visual-region="history-sidebar">
<strong className="history-sidebar-heading">Verlauf</strong>
<div className="history-filter-list">
{filterItems.map((item) => (
<button
aria-current={model.filter === item.id ? "page" : undefined}
className={`history-filter${model.filter === item.id ? " is-active" : ""}`}
key={item.id}
onClick={() => actions.onFilterChange(item.id)}
type="button"
>
<span>{item.label}</span>
<span>{model.counts[item.id]}</span>
</button>
))}
</div>
<button
className="history-sidebar-clear"
disabled={model.totalCount === 0 || model.loading}
onClick={actions.onClearHistory}
type="button"
>Verlauf leeren</button>
</div>
);
}
export function HistoryToolbar({ model, actions }: HistoryViewProps): ReactElement {
const selectedIds = model.selectedIds;
const selectedSet = new Set(selectedIds);
const restorable = model.rows.some((row) => selectedSet.has(row.id) && (row.urls?.length ?? 0) > 0);
return (
<Toolbar className="history-workspace-toolbar" data-visual-region="history-toolbar" label="Verlaufsaktionen">
<ToolbarGroup label="Einträge">
<button className="history-action" disabled={selectedIds.length === 0 || !restorable} onClick={() => actions.onRestore(selectedIds)} type="button">Erneut hinzufügen</button>
<button className="history-action" disabled={selectedIds.length !== 1} onClick={() => actions.onReveal(selectedIds[0])} type="button">Im Ordner zeigen</button>
<button className="history-action history-action-danger" disabled={selectedIds.length === 0} onClick={() => actions.onRemove(selectedIds)} type="button">Entfernen</button>
<button className="history-action" disabled={selectedIds.length === 0} onClick={actions.onClearSelection} type="button">Auswahl löschen</button>
</ToolbarGroup>
<ToolbarSearch
label="Verlauf durchsuchen"
onChange={(event: ChangeEvent<HTMLInputElement>) => actions.onQueryChange(event.target.value)}
placeholder="Name, Pfad, Hoster oder Provider"
value={model.query}
/>
</Toolbar>
);
}
export function HistoryContent({ model, actions }: HistoryViewProps): ReactElement {
const selected = new Set(model.selectedIds);
const expanded = new Set(model.expandedIds);
const visibleIds = model.rows.map((row) => row.id);
const allVisibleSelected = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));
const showEmpty = !model.loading && !model.error && model.rows.length === 0;
const emptyTitle = model.totalCount === 0 && !model.query && model.filter === "all"
? "Noch kein Verlauf"
: "Keine passenden Einträge";
return (
<section aria-label="Verlaufstabelle" className="history-content">
<DataTable className="history-table" label="Verlauf">
<DataTableHeader className="history-table-header">
<div className="history-table-header-row" role="row">
<span className="history-column-select" role="columnheader">
<input
aria-label="Alle sichtbaren Einträge auswählen"
checked={allVisibleSelected}
disabled={visibleIds.length === 0}
onChange={() => actions.onToggleSelectAll(visibleIds)}
type="checkbox"
/>
</span>
<span role="columnheader">Paket / Datei</span>
<span role="columnheader">Status</span>
<span role="columnheader">Größe</span>
<span role="columnheader">Hoster</span>
<span role="columnheader">Gestartet</span>
<span role="columnheader">Beendet</span>
<span role="columnheader">Aktion</span>
</div>
</DataTableHeader>
<DataTableBody className="history-table-body" data-visual-region="history-table-body">
{model.loading ? (
<DataTableEmpty description="Die gespeicherten Einträge werden geladen." title="Verlauf wird geladen" />
) : model.error ? (
<DataTableEmpty className="history-table-error" description="Öffne die Ansicht erneut, um es noch einmal zu versuchen." title={model.error} />
) : showEmpty ? (
<DataTableEmpty description={emptyTitle === "Noch kein Verlauf" ? "Abgeschlossene und gelöschte Pakete erscheinen hier." : "Passe Filter oder Suche an."} title={emptyTitle} />
) : (
model.rows.map((row) => {
const isSelected = selected.has(row.id);
const isExpanded = expanded.has(row.id);
const onContextMenu = (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
event.currentTarget.querySelector<HTMLButtonElement>(".history-row-action button")?.focus({ preventScroll: true });
actions.onContextMenu(row.id, event.clientX, event.clientY);
};
return (
<div className="history-row-group" key={row.id}>
<div
className={`history-row${isSelected ? " is-selected" : ""}`}
data-history-row-id={row.id}
onContextMenu={onContextMenu}
role="row"
>
<span className="history-column-select" role="cell">
<input
aria-label={`${row.name} auswählen`}
checked={isSelected}
onChange={() => actions.onToggleSelection(row.id)}
type="checkbox"
/>
</span>
<span className="history-row-name" role="cell">
<button
aria-expanded={isExpanded}
aria-label={isExpanded ? "Details ausblenden" : "Details anzeigen"}
className="history-expand"
onClick={(event) => {
event.stopPropagation();
actions.onToggleExpansion(row.id);
}}
type="button"
>{isExpanded ? "" : "+"}</button>
<span title={row.name}>{row.name}</span>
</span>
<span role="cell"><span className={`history-status history-status-${row.status}`}>{row.statusLabel}</span></span>
<span className="history-row-size" role="cell" title={row.sizeLabel}>{row.sizeLabel}</span>
<span className="history-row-hoster" role="cell" title={row.hoster}>{row.hoster}</span>
<span className="history-row-time" role="cell">{row.startedLabel}</span>
<span className="history-row-time" role="cell">{row.completedLabel}</span>
<span className="history-row-action" role="cell">
<button
aria-label={`Aktionen für ${row.name}`}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
actions.onContextMenu(row.id, rect.right, rect.bottom);
}}
type="button"
></button>
</span>
</div>
{isExpanded ? <HistoryRowDetails row={row} /> : null}
</div>
);
})
)}
</DataTableBody>
</DataTable>
</section>
);
}
export function HistoryFooter({ model }: Pick<HistoryViewProps, "model">): ReactElement {
const count = model.rows.length;
return (
<DataTableFooter
className="history-pagination"
data-visual-region="history-pagination"
pageSize={count}
paginationVisible
rangeLabel={count === 0 ? "0 von 0" : `1${count} von ${count}`}
/>
);
}
export function HistoryView({ model, actions }: HistoryViewProps): ReactElement {
return (
<div className="history-workspace-view">
<HistorySidebar actions={actions} model={model} />
<div className="history-view-main">
<HistoryToolbar actions={actions} model={model} />
<HistoryContent actions={actions} model={model} />
<HistoryFooter model={model} />
</div>
</div>
);
}
+254
View File
@@ -0,0 +1,254 @@
import type { DebridProvider, HistoryEntry } from "../../../shared/types";
export type HistoryFilter = "all" | "today" | "week" | "older" | "completed" | "deleted" | "failed";
export type HistoryViewStatus = HistoryEntry["status"] | "failed";
export type HistoryViewEntry = Omit<HistoryEntry, "status"> & { status: HistoryViewStatus };
export interface HistoryRow extends HistoryViewEntry {
hoster: string;
providerLabel: string;
startAt: number;
sizeLabel: string;
startedLabel: string;
completedLabel: string;
durationLabel: string;
averageSpeedLabel: string;
statusLabel: string;
}
export interface HistoryFilterCounts {
all: number;
today: number;
week: number;
older: number;
completed: number;
deleted: number;
failed: number;
}
export interface HistoryViewModel {
rows: HistoryRow[];
filter: HistoryFilter;
query: string;
selectedIds: string[];
expandedIds: string[];
counts: HistoryFilterCounts;
loading: boolean;
error: string;
totalCount: number;
}
const providerLabels: Record<DebridProvider, string> = {
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 statusLabels: Record<HistoryViewStatus, string> = {
completed: "Abgeschlossen",
deleted: "Gelöscht",
failed: "Fehlgeschlagen"
};
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
const dateFormatter = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
function formatBytes(bytes: number): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatDuration(durationSeconds: number): string {
const total = Math.max(0, Math.floor(Number.isFinite(durationSeconds) ? durationSeconds : 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours > 0) {
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function localDayStart(timestamp: number): number {
const date = new Date(timestamp);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function matchesTemporalFilter(entry: HistoryViewEntry, filter: HistoryFilter, now: number): boolean {
if (filter === "completed" || filter === "deleted" || filter === "failed") {
return entry.status === filter;
}
if (filter === "all") {
return true;
}
const todayStart = localDayStart(now);
const tomorrowStartDate = new Date(todayStart);
tomorrowStartDate.setDate(tomorrowStartDate.getDate() + 1);
const tomorrowStart = tomorrowStartDate.getTime();
const weekStartDate = new Date(todayStart);
weekStartDate.setDate(weekStartDate.getDate() - 6);
const weekStart = weekStartDate.getTime();
if (filter === "today") {
return entry.completedAt >= todayStart && entry.completedAt < tomorrowStart;
}
if (filter === "week") {
return entry.completedAt >= weekStart && entry.completedAt < todayStart;
}
return entry.completedAt < weekStart;
}
function normalizeSearch(value: string): string {
return value.trim().toLocaleLowerCase("de-DE");
}
export function deriveHistoryHoster(urls: string[] | undefined): string {
const hostnames: string[] = [];
const seen = new Set<string>();
for (const raw of urls ?? []) {
try {
const url = new URL(raw);
if (url.protocol !== "http:" && url.protocol !== "https:") {
continue;
}
const hostname = url.hostname.toLocaleLowerCase("de-DE");
if (!hostname || seen.has(hostname)) {
continue;
}
seen.add(hostname);
hostnames.push(hostname);
} catch {
continue;
}
}
return hostnames.length > 0 ? hostnames.join(", ") : "—";
}
export function deriveHistoryStartAt(entry: Pick<HistoryViewEntry, "completedAt" | "durationSeconds">): number {
const completedAt = Math.max(0, Number.isFinite(entry.completedAt) ? entry.completedAt : 0);
const durationMs = Math.max(0, Number.isFinite(entry.durationSeconds) ? entry.durationSeconds : 0) * 1000;
return Math.max(0, completedAt - durationMs);
}
function toHistoryRow(entry: HistoryViewEntry): HistoryRow {
const hoster = deriveHistoryHoster(entry.urls);
const providerLabel = entry.provider ? providerLabels[entry.provider] : "—";
const startAt = deriveHistoryStartAt(entry);
const durationSeconds = Math.max(0, entry.durationSeconds || 0);
const averageBytesPerSecond = durationSeconds > 0 ? entry.downloadedBytes / durationSeconds : 0;
return {
...entry,
hoster,
providerLabel,
startAt,
sizeLabel: `${formatBytes(entry.downloadedBytes)} / ${formatBytes(entry.totalBytes)}`,
startedLabel: dateFormatter.format(new Date(startAt)),
completedLabel: dateFormatter.format(new Date(Math.max(0, entry.completedAt))),
durationLabel: formatDuration(durationSeconds),
averageSpeedLabel: durationSeconds > 0 ? `${formatBytes(averageBytesPerSecond)}/s` : "—",
statusLabel: statusLabels[entry.status]
};
}
export function filterHistoryRows(
entries: HistoryViewEntry[],
filter: HistoryFilter,
query: string,
now = Date.now()
): HistoryRow[] {
const normalizedQuery = normalizeSearch(query);
return entries
.filter((entry) => matchesTemporalFilter(entry, filter, now))
.map(toHistoryRow)
.filter((row) => {
if (!normalizedQuery) {
return true;
}
const searchable = [
row.name,
row.outputDir,
row.hoster,
row.providerLabel,
...(row.urls ?? [])
].join("\n").toLocaleLowerCase("de-DE");
return searchable.includes(normalizedQuery);
});
}
function countHistoryFilters(entries: HistoryViewEntry[], now: number): HistoryFilterCounts {
return {
all: entries.length,
today: entries.filter((entry) => matchesTemporalFilter(entry, "today", now)).length,
week: entries.filter((entry) => matchesTemporalFilter(entry, "week", now)).length,
older: entries.filter((entry) => matchesTemporalFilter(entry, "older", now)).length,
completed: entries.filter((entry) => entry.status === "completed").length,
deleted: entries.filter((entry) => entry.status === "deleted").length,
failed: entries.filter((entry) => entry.status === "failed").length
};
}
export function buildHistoryViewModel(
entries: HistoryViewEntry[],
filter: HistoryFilter,
query: string,
selectedIds: Iterable<string>,
expandedIds: Iterable<string>,
loading: boolean,
error: string,
now = Date.now()
): HistoryViewModel {
const rows = filterHistoryRows(entries, filter, query, now);
const visibleIds = new Set(rows.map((row) => row.id));
return {
rows,
filter,
query,
selectedIds: [...selectedIds].filter((id) => visibleIds.has(id)),
expandedIds: [...expandedIds],
counts: countHistoryFilters(entries, now),
loading,
error,
totalCount: entries.length
};
}
export function pruneHistoryIds(current: Set<string>, availableIds: Iterable<string>): Set<string> {
if (current.size === 0) {
return current;
}
const available = new Set(availableIds);
const next = new Set<string>();
for (const id of current) {
if (available.has(id)) {
next.add(id);
}
}
return next.size === current.size ? current : next;
}
export function selectVisibleHistoryIds(visibleIds: Iterable<string>): Set<string> {
return new Set(visibleIds);
}
+366
View File
@@ -0,0 +1,366 @@
.history-workspace-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 520px;
overflow: hidden;
}
.history-view-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr) 60px;
min-width: 0;
}
.history-sidebar {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
min-height: 100%;
padding: 12px;
}
.history-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 28px;
}
.history-filter-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
}
.history-filter,
.history-sidebar-clear,
.history-action {
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--ui-text-secondary);
display: flex;
font: inherit;
height: 36px;
}
.history-filter {
justify-content: space-between;
padding: 0 10px;
text-align: left;
width: 100%;
}
.history-filter span:last-child {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
}
.history-filter:hover,
.history-sidebar-clear:hover:not(:disabled),
.history-action:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.history-filter.is-active {
background: var(--ui-active);
border-color: var(--ui-border);
color: var(--ui-text);
}
.history-sidebar-clear {
border-color: var(--ui-border);
justify-content: flex-start;
padding: 0 10px;
width: 100%;
}
.history-sidebar-clear:disabled,
.history-action:disabled {
cursor: default;
opacity: 0.45;
}
.history-sidebar,
.history-workspace-toolbar,
.history-content,
.history-pagination {
user-select: none;
}
.history-workspace-toolbar {
min-width: 0;
width: 100%;
}
.history-workspace-toolbar .ui-toolbar-search-input {
user-select: text;
}
.history-action {
background: var(--ui-input);
border-color: var(--ui-border);
padding: 0 12px;
white-space: nowrap;
}
.history-action-danger:not(:disabled) {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
color: var(--ui-danger);
}
.history-content {
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.history-content .history-table {
height: 100%;
overflow: auto;
}
.history-table-header-row,
.history-row {
align-items: center;
display: grid;
grid-template-columns: 48px minmax(190px, 1.45fr) minmax(112px, 0.75fr) minmax(150px, 1fr) minmax(130px, 0.9fr) minmax(135px, 0.9fr) minmax(135px, 0.9fr) 72px;
min-width: 1080px;
}
.history-table > .history-table-header {
height: 41px;
position: sticky;
top: 0;
z-index: 1;
}
.history-table-header-row {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
height: 41px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.history-table-header-row > span,
.history-row > span {
min-width: 0;
padding: 0 10px;
}
.history-table > .history-table-body {
overflow: visible;
}
.history-row-group {
min-width: 1080px;
}
.history-row {
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
height: 48px;
}
.history-row:hover {
background: var(--ui-hover);
}
.history-row.is-selected {
background: var(--ui-active);
}
.history-column-select {
display: grid;
place-items: center;
}
.history-row-name {
align-items: center;
color: var(--ui-text);
display: flex;
font-weight: 600;
gap: 8px;
overflow: hidden;
}
.history-row-name > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-expand,
.history-row-action button {
background: transparent;
border: 1px solid transparent;
border-radius: 4px;
color: var(--ui-text-secondary);
flex: 0 0 auto;
font: inherit;
height: 28px;
}
.history-expand {
width: 28px;
}
.history-row-action button {
width: 36px;
}
.history-expand:hover,
.history-row-action button:hover {
background: var(--ui-hover);
border-color: var(--ui-border);
color: var(--ui-text);
}
.history-status {
border: 1px solid var(--ui-border);
border-radius: 999px;
display: inline-flex;
font-size: 12px;
font-weight: 600;
line-height: 22px;
max-width: 100%;
overflow: hidden;
padding: 0 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-status-completed {
background: color-mix(in srgb, var(--ui-success) 16%, transparent);
border-color: color-mix(in srgb, var(--ui-success) 60%, var(--ui-border));
color: var(--ui-success);
}
.history-status-deleted {
background: color-mix(in srgb, var(--ui-warning) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-warning) 60%, var(--ui-border));
color: var(--ui-warning);
}
.history-status-failed {
background: color-mix(in srgb, var(--ui-danger) 15%, transparent);
border-color: color-mix(in srgb, var(--ui-danger) 60%, var(--ui-border));
color: var(--ui-danger);
}
.history-row-size,
.history-row-hoster,
.history-row-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-row-size,
.history-row-time {
font-variant-numeric: tabular-nums;
}
.history-row-action {
display: grid;
place-items: center;
}
.history-detail-row {
border-bottom: 1px solid var(--ui-border);
background: var(--ui-input);
min-width: 1080px;
}
.history-detail-cell {
padding: 14px 48px;
}
.history-details-grid {
display: grid;
gap: 12px 24px;
grid-template-columns: repeat(4, minmax(120px, 1fr));
margin: 0;
}
.history-details-grid > div {
display: grid;
gap: 4px;
min-width: 0;
}
.history-details-grid dt {
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.history-details-grid dd {
color: var(--ui-text-secondary);
margin: 0;
min-width: 0;
}
.history-details-grid .history-detail-wide {
grid-column: span 2;
}
.history-copyable {
overflow-wrap: anywhere;
user-select: text;
white-space: pre-wrap;
}
.history-table-error .ui-data-table-empty-title {
color: var(--ui-danger);
}
.history-pagination {
height: 60px;
}
@media (max-width: 1366px) {
.history-workspace-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.history-sidebar {
overflow: hidden;
}
.history-action {
padding: 0 9px;
}
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(170px, 1.25fr) minmax(108px, 0.7fr) minmax(140px, 0.9fr) minmax(120px, 0.8fr) minmax(125px, 0.8fr) minmax(125px, 0.8fr) 64px;
min-width: 996px;
}
.history-row-group,
.history-detail-row {
min-width: 996px;
}
}
@media (max-width: 1120px) {
.history-table-header-row,
.history-row {
grid-template-columns: 44px minmax(154px, 1.2fr) minmax(102px, 0.7fr) minmax(128px, 0.9fr) minmax(112px, 0.8fr) minmax(116px, 0.8fr) minmax(116px, 0.8fr) 60px;
min-width: 932px;
}
.history-row-group,
.history-detail-row {
min-width: 932px;
}
}
@@ -0,0 +1,585 @@
import {
cloneElement,
type ChangeEvent,
type DragEvent,
type KeyboardEvent,
type MouseEvent,
type ReactElement,
type UIEvent
} from "react";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableHeader
} from "../../ui/DataTable";
import { Dialog } from "../../ui/Dialog";
import {
ACCOUNT_COLUMNS,
type AccountAddFilter,
type AccountAddOption,
type AccountRowViewModel
} from "./settings-model";
export type AccountWorkspacePanel = "overview" | "rules";
export interface AccountRulesViewModel {
providerOrder: readonly string[];
routing: readonly string[];
autoFallback: boolean;
rememberCredentials?: boolean;
rotationEvents?: readonly { id: string; title: string; detail: string }[];
routingEntries?: readonly {
hosterId: string;
hosterLabel: string;
provider: string;
providers: readonly { value: string; label: string }[];
}[];
availableRoutingHosters?: readonly { value: string; label: string }[];
}
export interface AccountWorkspaceViewModel {
activePanel: AccountWorkspacePanel;
rows: readonly AccountRowViewModel[];
selectedIds: readonly string[];
busy: boolean;
error?: string;
allEnabled?: boolean;
statusSort?: "none" | "desc" | "asc";
rules: AccountRulesViewModel;
}
export interface AccountWorkspaceActions {
onPanelChange: (panel: AccountWorkspacePanel) => void;
onSelect: (rowId: string) => void;
onToggleEnabled: (rowId: string) => void;
onEdit: (rowId: string) => void;
onContextMenu: (rowId: string, x: number, y: number) => void;
onAdd: () => void;
onRemoveSelected: () => void;
onCheckAll: () => void;
onSetAllEnabled?: (enabled: boolean) => void;
onStatusSort?: () => void;
onMoveProvider?: (index: number, direction: -1 | 1) => void;
onProviderDragStart?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDragOver?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDrop?: (event: DragEvent<HTMLElement>, index: number) => void;
onProviderDragEnd?: () => void;
onToggleAutoFallback?: (enabled: boolean) => void;
onToggleRememberCredentials?: (enabled: boolean) => void;
onRoutingProviderChange?: (hosterId: string, provider: string) => void;
onRoutingRemove?: (hosterId: string) => void;
onRoutingAdd?: (hosterId: string) => void;
}
export interface AccountWorkspaceProps {
model: AccountWorkspaceViewModel;
actions: AccountWorkspaceActions;
}
export interface AccountDialogField {
id: string;
label: string;
type: "text" | "password" | "number" | "textarea";
value: string;
placeholder?: string;
help?: string;
}
export interface AccountAddDialogModel {
open: boolean;
query: string;
filter: AccountAddFilter;
options: readonly AccountAddOption[];
selectedOptionId: string | null;
fields: readonly AccountDialogField[];
error: string;
busy: boolean;
}
export interface AccountAddDialogActions {
onQueryChange: (value: string) => void;
onFilterChange: (filter: AccountAddFilter) => void;
onOptionSelect: (optionId: string) => void;
onFieldChange: (fieldId: string, value: string) => void;
onClose: () => void;
onSubmit: () => void;
}
export interface AccountEditDialogModel {
open: boolean;
hoster: string;
mode: string;
identity: string;
enabled: boolean;
fields: readonly AccountDialogField[];
error: string;
busy: boolean;
}
export interface AccountEditDialogActions {
onFieldChange: (fieldId: string, value: string) => void;
onClose: () => void;
onCheck: () => void;
onSave: () => void;
onRemove: () => void;
onToggleEnabled: () => void;
}
function AccountDialogFields({
fields,
onChange
}: {
fields: readonly AccountDialogField[];
onChange: (fieldId: string, value: string) => void;
}): ReactElement {
return (
<div className="settings-account-dialog-fields">
{fields.map((field) => (
<label className="settings-account-dialog-field" key={field.id}>
<span>{field.label}</span>
{field.type === "textarea" ? (
<textarea
className="settings-control settings-account-dialog-textarea"
onChange={(event) => onChange(field.id, event.target.value)}
placeholder={field.placeholder}
rows={4}
value={field.value}
/>
) : (
<input
autoComplete={field.type === "password" ? "off" : undefined}
className="settings-control"
inputMode={field.type === "number" ? "decimal" : undefined}
onChange={(event) => onChange(field.id, event.target.value)}
placeholder={field.placeholder}
type={field.type}
value={field.value}
/>
)}
{field.help ? <span className="settings-field-help">{field.help}</span> : null}
</label>
))}
</div>
);
}
function AccountRow({
row,
selected,
busy,
actions
}: {
row: AccountRowViewModel;
selected: boolean;
busy: boolean;
actions: AccountWorkspaceActions;
}): ReactElement {
const selectRow = (): void => actions.onSelect(row.id);
const onClick = (): void => selectRow();
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) {
return;
}
event.preventDefault();
selectRow();
};
const openContextMenu = (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
actions.onContextMenu(row.id, event.clientX, event.clientY);
};
return (
<div
aria-selected={selected}
className={`settings-account-row${selected ? " is-selected" : ""}${row.problem ? " has-problem" : ""}${!row.enabled ? " is-disabled" : ""}`}
onClick={onClick}
onContextMenu={openContextMenu}
onDoubleClick={() => actions.onEdit(row.id)}
onKeyDown={onKeyDown}
role="row"
tabIndex={0}
>
<span className="settings-account-column-enable" role="cell">
<input
aria-label={`${row.hoster} ${row.enabled ? "deaktivieren" : "aktivieren"}`}
checked={row.enabled}
disabled={busy}
onChange={() => actions.onToggleEnabled(row.id)}
onClick={(event) => event.stopPropagation()}
type="checkbox"
/>
</span>
<span className="settings-account-hoster" role="cell" title={`${row.hoster} · ${row.mode}`}>
<img alt="" aria-hidden="true" draggable={false} height="20" src={row.icon} width="20" />
<span>
<strong>{row.hoster}</strong>
<small>{row.mode}</small>
</span>
</span>
<span className="settings-account-status" role="cell">
<span className={`settings-account-status-badge is-${row.status.tone}`}>{row.status.text}</span>
</span>
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
<span className="settings-account-expires" role="cell">{row.expires}</span>
<span className="settings-account-credential" role="cell">{row.credential}</span>
<span className="settings-account-column-actions" role="cell">
<button
aria-label={`${row.hoster} Aktionen`}
className="settings-account-action-button"
disabled={busy}
onClick={(event) => {
event.stopPropagation();
const rect = event.currentTarget.getBoundingClientRect();
actions.onContextMenu(row.id, rect.right, rect.bottom);
}}
type="button"
></button>
</span>
</div>
);
}
function syncAccountTableScroll(event: UIEvent<HTMLDivElement>): void {
const header = event.currentTarget.parentElement?.querySelector<HTMLElement>(".settings-account-table-header");
if (header) {
header.scrollLeft = event.currentTarget.scrollLeft;
}
}
function AccountOverview({ model, actions }: AccountWorkspaceProps): ReactElement {
const selectedIds = new Set(model.selectedIds);
return (
<>
<DataTable className="settings-account-table" label="Accounts">
<DataTableHeader className="settings-account-table-header">
<div className="settings-account-table-grid" role="row">
<span aria-label="Aktiviert" className="settings-account-column-enable" role="columnheader" />
{ACCOUNT_COLUMNS.map((column) => (
<span key={column} role="columnheader">
{column === "Status" && actions.onStatusSort ? (
<button className="settings-account-sort" onClick={actions.onStatusSort} type="button">
{column}{model.statusSort === "desc" ? " ▼" : model.statusSort === "asc" ? " ▲" : ""}
</button>
) : column}
</span>
))}
<span aria-label="Aktionen" className="settings-account-column-actions" role="columnheader" />
</div>
</DataTableHeader>
<DataTableBody className="settings-account-table-body" data-visual-region="accounts-table-body" onScroll={syncAccountTableScroll}>
{model.busy && model.rows.length === 0 ? (
<DataTableEmpty description="Die Accountdaten werden aktualisiert." title="Accounts werden geladen" />
) : model.error ? (
<DataTableEmpty className="settings-account-table-error" description="Die gespeicherten Accounts bleiben unverändert." title={model.error} />
) : model.rows.length === 0 ? (
<DataTableEmpty description="Füge einen Account hinzu, um Downloads über einen Anbieter zu starten." title="Noch keine Accounts" />
) : model.rows.map((row) => cloneElement(
AccountRow({ actions, busy: model.busy, row, selected: selectedIds.has(row.id) }),
{ key: row.id }
))}
</DataTableBody>
</DataTable>
<div className="settings-account-local-actions">
<div>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onAdd} type="button"> Hinzufügen</button>
<button className="settings-button settings-button-secondary" disabled={model.busy || model.selectedIds.length === 0} onClick={actions.onRemoveSelected} type="button"> Entfernen</button>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheckAll} type="button"> Aktualisieren</button>
</div>
<span>{model.rows.length} {model.rows.length === 1 ? "Account" : "Accounts"}</span>
</div>
</>
);
}
function AccountRules({ model, actions }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-rules">
<section className="settings-rule-section">
<h3>Provider-Reihenfolge</h3>
<p>Lege fest, in welcher Reihenfolge verfügbare Provider verwendet werden.</p>
{model.rules.providerOrder.length === 0 ? (
<span className="settings-rule-empty">Keine Provider konfiguriert.</span>
) : (
<ol className="settings-provider-order">
{model.rules.providerOrder.map((provider, index) => (
<li
draggable={Boolean(actions.onProviderDragStart)}
key={`${provider}-${index}`}
onDragEnd={actions.onProviderDragEnd}
onDragOver={(event) => actions.onProviderDragOver?.(event, index)}
onDragStart={(event) => actions.onProviderDragStart?.(event, index)}
onDrop={(event) => actions.onProviderDrop?.(event, index)}
>
<span>{provider}</span>
{actions.onMoveProvider ? (
<span className="settings-provider-order-actions">
<button aria-label={`${provider} nach oben`} disabled={index === 0} onClick={() => actions.onMoveProvider?.(index, -1)} type="button"></button>
<button aria-label={`${provider} nach unten`} disabled={index === model.rules.providerOrder.length - 1} onClick={() => actions.onMoveProvider?.(index, 1)} type="button"></button>
</span>
) : null}
</li>
))}
</ol>
)}
<label className="settings-rule-toggle">
<input
checked={model.rules.autoFallback}
disabled={!actions.onToggleAutoFallback}
onChange={(event) => actions.onToggleAutoFallback?.(event.target.checked)}
type="checkbox"
/>
<span>Automatischer Fallback</span>
</label>
{typeof model.rules.rememberCredentials === "boolean" ? (
<label className="settings-rule-toggle">
<input
checked={model.rules.rememberCredentials}
disabled={!actions.onToggleRememberCredentials}
onChange={(event) => actions.onToggleRememberCredentials?.(event.target.checked)}
type="checkbox"
/>
<span>Zugangsdaten lokal speichern</span>
</label>
) : null}
</section>
<section className="settings-rule-section">
<h3>Hoster-Routing</h3>
<p>Eigene Zuordnungen überschreiben für den jeweiligen Hoster die Standardreihenfolge.</p>
{model.rules.routingEntries ? (
<>
{model.rules.routingEntries.length === 0 ? <span className="settings-rule-empty">Keine eigenen Zuordnungen.</span> : (
<div className="settings-routing-editor">
{model.rules.routingEntries.map((entry) => (
<div className="settings-routing-editor-row" key={entry.hosterId}>
<span>{entry.hosterLabel}</span>
<select
aria-label={`Provider für ${entry.hosterLabel}`}
className="settings-control"
onChange={(event) => actions.onRoutingProviderChange?.(entry.hosterId, event.target.value)}
value={entry.provider}
>
{entry.providers.map((provider) => <option key={provider.value} value={provider.value}>{provider.label}</option>)}
</select>
<button aria-label={`${entry.hosterLabel} Zuordnung entfernen`} className="settings-button settings-button-danger" onClick={() => actions.onRoutingRemove?.(entry.hosterId)} type="button">Entfernen</button>
</div>
))}
</div>
)}
{model.rules.availableRoutingHosters ? (
<select
aria-label="Hoster-Routing hinzufügen"
className="settings-control settings-routing-add"
onChange={(event) => {
if (event.target.value) {
actions.onRoutingAdd?.(event.target.value);
}
event.target.value = "";
}}
value=""
>
<option disabled value="">Hoster hinzufügen</option>
{model.rules.availableRoutingHosters.map((hoster) => <option key={hoster.value} value={hoster.value}>{hoster.label}</option>)}
<option value="__custom">Eigener Hoster</option>
</select>
) : null}
</>
) : model.rules.routing.length === 0 ? (
<span className="settings-rule-empty">Keine eigenen Zuordnungen.</span>
) : (
<ul className="settings-routing-list">
{model.rules.routing.map((route, index) => <li className="settings-copyable" key={`${route}-${index}`}>{route}</li>)}
</ul>
)}
</section>
{model.rules.rotationEvents ? (
<section className="settings-rule-section">
<h3>Rotations-Verlauf</h3>
{model.rules.rotationEvents.length === 0 ? (
<span className="settings-rule-empty">Noch keine Rotations-Ereignisse.</span>
) : model.rules.rotationEvents.map((event) => (
<div className="settings-rotation-event" key={event.id}>
<strong>{event.title}</strong>
<span>{event.detail}</span>
</div>
))}
</section>
) : null}
</div>
);
}
export function AccountWorkspace({ model, actions }: AccountWorkspaceProps): ReactElement {
return (
<div className="settings-account-workspace">
<header className="settings-account-heading">
<div>
<h2>Accountverwaltung</h2>
<p>Accounts hinzufügen, prüfen und verwalten.</p>
</div>
{typeof model.allEnabled === "boolean" && actions.onSetAllEnabled ? (
<label className="settings-rule-toggle settings-account-all-enabled">
<input
checked={model.allEnabled}
disabled={model.busy || model.rows.length === 0}
onChange={(event) => actions.onSetAllEnabled?.(event.target.checked)}
type="checkbox"
/>
<span>Accounts zum Herunterladen verwenden</span>
</label>
) : null}
</header>
<div aria-label="Accountverwaltung" className="settings-account-tabs" role="tablist">
<button
aria-controls="settings-account-overview"
aria-selected={model.activePanel === "overview"}
id="settings-account-overview-tab"
onClick={() => actions.onPanelChange("overview")}
role="tab"
type="button"
>Übersicht</button>
<button
aria-controls="settings-account-rules"
aria-selected={model.activePanel === "rules"}
id="settings-account-rules-tab"
onClick={() => actions.onPanelChange("rules")}
role="tab"
type="button"
>Verwendungsregeln</button>
</div>
<div
aria-labelledby="settings-account-overview-tab"
className="settings-account-panel"
hidden={model.activePanel !== "overview"}
id="settings-account-overview"
role="tabpanel"
>
{AccountOverview({ actions, model })}
</div>
<div
aria-labelledby="settings-account-rules-tab"
className="settings-account-panel"
hidden={model.activePanel !== "rules"}
id="settings-account-rules"
role="tabpanel"
>
{AccountRules({ actions, model })}
</div>
</div>
);
}
export function AccountAddDialog({
model,
actions
}: {
model: AccountAddDialogModel;
actions: AccountAddDialogActions;
}): ReactElement | null {
const onFilterChange = (event: ChangeEvent<HTMLSelectElement>): void => {
actions.onFilterChange(event.target.value as AccountAddFilter);
};
return (
<Dialog
actions={(
<>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onClose} type="button">Abbrechen</button>
<button className="settings-button settings-button-primary" disabled={model.busy || !model.selectedOptionId} onClick={actions.onSubmit} type="button">Prüfen und speichern</button>
</>
)}
actionsClassName="settings-account-dialog-actions"
bodyClassName="settings-account-dialog-body"
description="Wähle einen Dienst und trage die passenden Zugangsdaten ein."
onClose={actions.onClose}
open={model.open}
size="account"
title="Account hinzufügen"
>
<div className="settings-account-picker-controls">
<input
aria-label="Accounts durchsuchen"
className="settings-control"
onChange={(event) => actions.onQueryChange(event.target.value)}
placeholder="Dienst oder Zugangstyp suchen"
type="search"
value={model.query}
/>
<select aria-label="Account-Typ filtern" className="settings-control" onChange={onFilterChange} value={model.filter}>
<option value="all">Alle</option>
<option value="api">API</option>
<option value="web">Web</option>
</select>
</div>
<div aria-label="Verfügbare Account-Typen" className="settings-account-picker" role="listbox">
{model.options.length === 0 ? (
<span className="settings-account-picker-empty">Keine passenden Account-Typen.</span>
) : model.options.map((option) => {
const selected = option.id === model.selectedOptionId;
return (
<div className="settings-account-picker-entry" key={option.id}>
<button
aria-selected={selected}
className={`settings-account-picker-row${selected ? " is-selected" : ""}`}
onClick={() => actions.onOptionSelect(option.id)}
role="option"
type="button"
>
<span>
<strong>{option.title}</strong>
<small>{option.description}</small>
</span>
<span>
<strong>{option.mode}</strong>
<small>{option.functionLabel}</small>
</span>
</button>
{selected ? <AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} /> : null}
</div>
);
})}
</div>
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
</Dialog>
);
}
export function AccountEditDialog({
model,
actions
}: {
model: AccountEditDialogModel;
actions: AccountEditDialogActions;
}): ReactElement | null {
return (
<Dialog
actions={(
<>
<button className="settings-button settings-button-danger" disabled={model.busy} onClick={actions.onRemove} type="button">Entfernen</button>
<span className="settings-account-dialog-action-spacer" />
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onClose} type="button">Abbrechen</button>
<button className="settings-button settings-button-secondary" disabled={model.busy} onClick={actions.onCheck} type="button">Prüfen</button>
<button className="settings-button settings-button-primary" disabled={model.busy} onClick={actions.onSave} type="button">Speichern</button>
</>
)}
actionsClassName="settings-account-dialog-actions"
bodyClassName="settings-account-dialog-body"
description="Bearbeite ausschließlich den ausgewählten Account."
onClose={actions.onClose}
open={model.open}
size="account"
title="Account bearbeiten"
>
<div className="settings-account-edit-identity">
<span>{model.hoster} · {model.mode}</span>
<strong className="settings-copyable">{model.identity}</strong>
</div>
<label className="settings-rule-toggle settings-account-edit-enabled">
<input checked={model.enabled} disabled={model.busy} onChange={actions.onToggleEnabled} type="checkbox" />
<span>Account aktiviert</span>
</label>
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
</Dialog>
);
}
@@ -0,0 +1,177 @@
import { cloneElement, type ChangeEvent, type ReactElement } from "react";
import type {
SettingsFieldViewModel,
SettingsFormViewModel,
SettingsTextFieldViewModel
} from "./settings-model";
export interface SettingsFormActions {
onChange: (fieldId: string, value: string | boolean) => void;
onAction: (fieldId: string) => void;
onCommit?: (fieldId: string, value: string) => void;
}
export interface SettingsFormProps {
model: SettingsFormViewModel;
actions: SettingsFormActions;
}
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
}
function TextControl({ field, actions }: { field: SettingsTextFieldViewModel; actions: SettingsFormActions }): ReactElement {
const describedBy = field.help ? `${field.id}-help` : undefined;
const onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
actions.onChange(field.id, event.target.value);
};
const control = field.kind === "textarea" ? (
<textarea
aria-describedby={describedBy}
className="settings-control settings-textarea"
disabled={field.disabled}
id={field.id}
onChange={onChange}
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
placeholder={field.placeholder}
rows={4}
value={field.value}
/>
) : (
<input
aria-describedby={describedBy}
className={`settings-control${field.kind === "path" ? " settings-copyable" : ""}`}
disabled={field.disabled}
id={field.id}
inputMode={field.inputMode}
max={field.max}
min={field.min}
onChange={onChange}
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
placeholder={field.placeholder}
step={field.step}
type={field.kind === "number" ? "number" : "text"}
value={field.value}
/>
);
return (
<div className="settings-field">
<label htmlFor={field.id}>{field.label}</label>
{field.actionLabel ? (
<div className="settings-control-row">
{control}
<button
className="settings-button settings-button-secondary"
disabled={field.disabled}
onClick={() => actions.onAction(field.id)}
type="button"
>{field.actionLabel}</button>
</div>
) : control}
<FieldHelp field={field} />
</div>
);
}
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
return <TextControl actions={actions} field={field} />;
}
if (field.kind === "select") {
return (
<div className="settings-field">
<label htmlFor={field.id}>{field.label}</label>
<select
aria-describedby={field.help ? `${field.id}-help` : undefined}
className="settings-control"
disabled={field.disabled}
id={field.id}
onChange={(event) => actions.onChange(field.id, event.target.value)}
value={field.value}
>
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<FieldHelp field={field} />
</div>
);
}
if (field.kind === "theme") {
return (
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
<legend>{field.label}</legend>
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
{field.options.map((option) => (
<button
aria-checked={field.value === option.value}
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
key={option.value}
onClick={() => actions.onChange(field.id, option.value)}
role="radio"
type="button"
>
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
<span>{option.label}</span>
</button>
))}
</div>
<FieldHelp field={field} />
</fieldset>
);
}
if (field.kind === "switch") {
return (
<div className="settings-field settings-switch-field">
<div>
<span className="settings-switch-label" id={`${field.id}-label`}>{field.label}</span>
<FieldHelp field={field} />
</div>
<button
aria-checked={field.value}
aria-describedby={field.help ? `${field.id}-help` : undefined}
aria-labelledby={`${field.id}-label`}
className={`settings-switch${field.value ? " is-on" : ""}`}
disabled={field.disabled}
onClick={() => actions.onChange(field.id, !field.value)}
role="switch"
type="button"
><span /></button>
</div>
);
}
return (
<div className="settings-field settings-action-field">
<div>
<span className="settings-switch-label">{field.label}</span>
<FieldHelp field={field} />
</div>
<button
className="settings-button settings-button-secondary"
disabled={field.disabled}
onClick={() => actions.onAction(field.id)}
type="button"
>{field.actionLabel}</button>
</div>
);
}
export function SettingsForm({ model, actions }: SettingsFormProps): ReactElement {
return (
<div className="settings-form-column">
<header className="settings-form-heading">
<h2>{model.title}</h2>
<p>{model.description}</p>
</header>
{model.groups.map((group) => (
<section className="settings-form-group" key={group.id}>
<header>
<h3>{group.title}</h3>
{group.description ? <p>{group.description}</p> : null}
</header>
<div className="settings-form-fields">
{group.fields.map((field) => cloneElement(SettingsField({ actions, field }), { key: field.id }))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,99 @@
import type { ReactElement } from "react";
import {
SETTINGS_SECTIONS,
getSettingsSaveLabel,
type SettingsFormViewModel,
type SettingsSaveState,
type SettingsSection
} from "./settings-model";
import {
AccountWorkspace,
type AccountWorkspaceActions,
type AccountWorkspaceViewModel
} from "./AccountWorkspace";
import { SettingsForm, type SettingsFormActions } from "./SettingsForm";
import "./settings.css";
export type SettingsViewRegion = "all" | "sidebar" | "content";
export interface SettingsViewModel {
section: SettingsSection;
saveState: SettingsSaveState;
form: SettingsFormViewModel;
accounts: AccountWorkspaceViewModel;
}
export interface SettingsViewActions {
onSectionChange: (section: SettingsSection) => void;
onSave: () => void;
form: SettingsFormActions;
accounts: AccountWorkspaceActions;
}
export interface SettingsViewProps {
model: SettingsViewModel;
actions: SettingsViewActions;
region?: SettingsViewRegion;
}
export function SettingsSidebar({ model, actions }: SettingsViewProps): ReactElement {
return (
<nav aria-label="Einstellungen" className="settings-sidebar" data-visual-region="settings-sidebar">
<div className="settings-sidebar-heading">
<strong>Einstellungen</strong>
</div>
<div className="settings-sidebar-list">
{SETTINGS_SECTIONS.map((section) => (
<button
aria-current={model.section === section.id ? "page" : undefined}
className={`settings-sidebar-item${model.section === section.id ? " is-active" : ""}`}
key={section.id}
onClick={() => actions.onSectionChange(section.id)}
type="button"
>{section.label}</button>
))}
</div>
</nav>
);
}
export function SettingsContent({ model, actions }: SettingsViewProps): ReactElement {
const saveLabel = getSettingsSaveLabel(model.saveState);
const saveDisabled = model.saveState === "clean" || model.saveState === "saved" || model.saveState === "saving";
return (
<section aria-label="Einstellungsbereich" className="settings-content settings-static">
<header className="settings-content-header">
<div>
<h1>Einstellungen</h1>
<span aria-live="polite" className={`settings-save-state is-${model.saveState}`} role="status">{saveLabel}</span>
</div>
<button
className="settings-button settings-button-primary"
disabled={saveDisabled}
onClick={actions.onSave}
type="button"
>Einstellungen speichern</button>
</header>
<div className={`settings-content-body${model.section === "accounts" ? " is-accounts" : ""}`}>
{model.section === "accounts"
? <AccountWorkspace actions={actions.accounts} model={model.accounts} />
: <SettingsForm actions={actions.form} model={model.form} />}
</div>
</section>
);
}
export function SettingsView({ model, actions, region = "all" }: SettingsViewProps): ReactElement {
if (region === "sidebar") {
return <SettingsSidebar actions={actions} model={model} />;
}
if (region === "content") {
return <SettingsContent actions={actions} model={model} />;
}
return (
<div className="settings-view">
<SettingsSidebar actions={actions} model={model} />
<SettingsContent actions={actions} model={model} />
</div>
);
}
@@ -0,0 +1,652 @@
import type { AppSettings } from "../../../shared/types";
import type { AccountService } from "../../account-edit";
import { resolveAccountUsername } from "../../account-ui";
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
export type SettingsSaveState = "clean" | "dirty" | "saving" | "saved" | "error";
export const SETTINGS_SECTIONS: readonly { id: SettingsSection; label: string }[] = [
{ id: "allgemein", label: "Allgemein" },
{ id: "accounts", label: "Accounts" },
{ id: "extract", label: "Entpacken" },
{ id: "speed", label: "Geschwindigkeit" },
{ id: "cleanup", label: "Bereinigung" },
{ id: "updates", label: "Updates" }
];
export const ACCOUNT_COLUMNS = [
"Hoster",
"Status",
"Download-Traffic übrig",
"Benutzername",
"Verfallsdatum",
"Passwort/Zugang"
] as const;
export function getSettingsSaveLabel(state: SettingsSaveState): string {
switch (state) {
case "dirty":
return "Ungespeicherte Änderungen";
case "saving":
return "Wird gespeichert…";
case "error":
return "Speichern fehlgeschlagen";
case "clean":
case "saved":
return "Gespeichert";
}
}
export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled";
export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled";
export interface AccountRowSource {
identityId: string;
service: AccountService;
hoster: string;
mode: string;
icon?: string;
enabled: boolean;
status: {
state: AccountStatusSourceState;
message: string;
premiumUntilMs: number | null;
email?: string;
};
dailyLimitBytes?: number;
dailyUsageBytes?: number;
username: string;
credentialKind: "password" | "api-key" | "protected";
canCheck: boolean;
}
export interface AccountRowViewModel {
id: string;
service: AccountService;
hoster: string;
mode: string;
icon: string;
enabled: boolean;
selected: boolean;
status: {
tone: AccountStatusTone;
text: string;
};
traffic: string;
username: string;
expires: string;
credential: string;
canCheck: boolean;
problem: boolean;
premiumUntilMs: number | null;
}
export type AccountAddFilter = "all" | "api" | "web";
export interface AccountAddOption {
id: string;
service: AccountService;
title: string;
mode: string;
description: string;
functionLabel: string;
filter: Exclude<AccountAddFilter, "all">;
multi: boolean;
icon?: string;
}
export interface AccountAddDraft {
selectedId: string | null;
login: string;
password: string;
token: string;
dailyLimitGb: string;
}
export interface TargetedAccountCheck {
service: "megadebrid-api" | "debridlink";
expectedStatusId: string;
}
export interface SettingsFieldBase {
id: string;
label: string;
help?: string;
disabled?: boolean;
}
export interface SettingsTextFieldViewModel extends SettingsFieldBase {
kind: "text" | "path" | "number" | "textarea";
value: string;
placeholder?: string;
inputMode?: "decimal" | "numeric" | "text" | "url";
min?: number;
max?: number;
step?: number;
actionLabel?: string;
commitOnBlur?: boolean;
}
export interface SettingsSelectFieldViewModel extends SettingsFieldBase {
kind: "select";
value: string;
options: readonly { value: string; label: string }[];
}
export interface SettingsSwitchFieldViewModel extends SettingsFieldBase {
kind: "switch";
value: boolean;
}
export interface SettingsThemeFieldViewModel extends SettingsFieldBase {
kind: "theme";
value: string;
options: readonly { value: string; label: string }[];
}
export interface SettingsActionFieldViewModel extends SettingsFieldBase {
kind: "action";
actionLabel: string;
}
export type SettingsFieldViewModel =
| SettingsTextFieldViewModel
| SettingsSelectFieldViewModel
| SettingsSwitchFieldViewModel
| SettingsThemeFieldViewModel
| SettingsActionFieldViewModel;
export interface SettingsFormGroupViewModel {
id: string;
title: string;
description?: string;
fields: readonly SettingsFieldViewModel[];
}
export interface SettingsFormViewModel {
title: string;
description: string;
groups: readonly SettingsFormGroupViewModel[];
}
export interface SettingsFormProjectionInput {
settings: AppSettings;
section: SettingsSection;
speedLimitInput: string;
scheduleSpeedInputs: Readonly<Record<string, string>>;
themeChoice?: "light" | "dark" | "system";
}
export function buildSettingsFormViewModel({
settings,
section,
speedLimitInput,
scheduleSpeedInputs,
themeChoice = settings.theme
}: SettingsFormProjectionInput): SettingsFormViewModel {
if (section === "extract") {
return {
title: "Entpacken",
description: "Ablauf, Ziel, Tonspur, Ablageform und Leistung.",
groups: [
{
id: "extract-target",
title: "Ziel und Ablauf",
fields: [
{ id: "extractDir", kind: "path", label: "Entpacken nach", value: settings.extractDir, actionLabel: "Wählen", help: "Zielordner für entpackte Dateien." },
{ id: "autoExtract", kind: "switch", label: "Automatisch entpacken", value: settings.autoExtract },
{ id: "autoSkipExtracted", kind: "switch", label: "Bereits Entpacktes überspringen", value: settings.autoSkipExtracted },
{ id: "hideExtractedItems", kind: "switch", label: "Entpackte Einträge ausblenden", value: settings.hideExtractedItems },
{ id: "autoExtractWhenStopped", kind: "switch", label: "Entpacken auch ohne laufende Sitzung", value: settings.autoExtractWhenStopped }
]
},
{
id: "extract-audio",
title: "Deutsche Tonspur",
fields: [
{ id: "keepGermanAudioOnly", kind: "switch", label: "Nur deutsche Tonspur behalten", value: settings.keepGermanAudioOnly, help: "Benötigt ffmpeg." },
{
id: "germanAudioMode",
kind: "select",
label: "Welche Tonspur behalten",
value: settings.germanAudioMode,
disabled: !settings.keepGermanAudioOnly,
options: [
{ value: "tag", label: "Deutsche Spur per Sprach-Tag" },
{ value: "first", label: "Immer erste Tonspur" }
]
}
]
},
{
id: "extract-layout",
title: "Ablageform",
fields: [
{ id: "autoRename4sf4sj", kind: "switch", label: "Automatisch umbenennen", value: settings.autoRename4sf4sj },
{ id: "createExtractSubfolder", kind: "switch", label: "In Paket-Unterordner ablegen", value: settings.createExtractSubfolder },
{ id: "collectMkvToLibrary", kind: "switch", label: "Videos in Sammelordner verschieben", value: settings.collectMkvToLibrary },
{ id: "mkvLibraryDir", kind: "path", label: "Video-Sammelordner", value: settings.mkvLibraryDir, disabled: !settings.collectMkvToLibrary, actionLabel: "Wählen" }
]
},
{
id: "extract-performance",
title: "Leistung",
fields: [
{ id: "hybridExtract", kind: "switch", label: "Hybrid-Entpacken", value: settings.hybridExtract },
{ id: "maxParallelExtract", kind: "number", label: "Gleichzeitige Entpackungen", value: String(settings.maxParallelExtract), min: 1, max: 8 },
{
id: "extractCpuPriority",
kind: "select",
label: "CPU-Priorität beim Entpacken",
value: settings.extractCpuPriority,
options: [
{ value: "high", label: "Hoch (80% CPU)" },
{ value: "middle", label: "Mittel (50% CPU)" },
{ value: "low", label: "Niedrig (25% CPU)" }
]
}
]
},
{
id: "extract-passwords",
title: "Passwörter",
fields: [
{ id: "archivePasswordList", kind: "textarea", label: "Passwortliste für Archive", value: settings.archivePasswordList, placeholder: "Ein Passwort pro Zeile" }
]
}
]
};
}
if (section === "speed") {
const scheduleGroups: SettingsFormGroupViewModel[] = (settings.bandwidthSchedules || []).map((schedule, index) => {
const scheduleKey = schedule.id || `schedule-${index}`;
return {
id: `schedule:${index}`,
title: `Zeitregel ${index + 1}`,
fields: [
{ id: `schedule:${index}:startHour`, kind: "number", label: "Von (Stunde)", value: String(schedule.startHour), min: 0, max: 23 },
{ id: `schedule:${index}:endHour`, kind: "number", label: "Bis (Stunde)", value: String(schedule.endHour), min: 0, max: 23 },
{
id: `schedule:${index}:speedLimitMbps`,
kind: "number",
label: "Limit (MB/s)",
value: scheduleSpeedInputs[scheduleKey] ?? String(schedule.speedLimitKbps / 1024),
min: 0,
step: 0.1,
commitOnBlur: true
},
{ id: `schedule:${index}:enabled`, kind: "switch", label: "Zeitregel aktiviert", value: schedule.enabled },
{ id: `schedule:${index}:remove`, kind: "action", label: "Zeitregel entfernen", actionLabel: "Entfernen" }
]
};
});
return {
title: "Geschwindigkeit",
description: "Tempo, Wiederverbindung und zeitgesteuerte Bandbreitenregeln.",
groups: [
{
id: "speed-limit",
title: "Tempo-Begrenzung",
fields: [
{ id: "speedLimitEnabled", kind: "switch", label: "Geschwindigkeit begrenzen", value: settings.speedLimitEnabled },
{ id: "speedLimitInput", kind: "number", label: "Höchstgeschwindigkeit (MB/s)", value: speedLimitInput, min: 0, step: 0.1, disabled: !settings.speedLimitEnabled, commitOnBlur: true },
{
id: "speedLimitMode",
kind: "select",
label: "Limit gilt für",
value: settings.speedLimitMode,
disabled: !settings.speedLimitEnabled,
options: [
{ value: "global", label: "Global" },
{ value: "per_download", label: "Pro Download" }
]
}
]
},
{
id: "speed-connection",
title: "Verbindung",
fields: [
{ id: "autoReconnect", kind: "switch", label: "Automatisch neu verbinden", value: settings.autoReconnect },
{ id: "reconnectWaitSeconds", kind: "number", label: "Wartezeit vor neuem Versuch (Sek.)", value: String(settings.reconnectWaitSeconds), min: 10, max: 600 }
]
},
...scheduleGroups,
{
id: "speed-schedule-add",
title: "Bandbreitenplanung",
description: "Jede Regel legt für ein Zeitfenster ein eigenes Limit fest.",
fields: [{ id: "schedule:add", kind: "action", label: "Weitere Zeitregel", actionLabel: "Zeitregel hinzufügen" }]
}
]
};
}
if (section === "cleanup") {
return {
title: "Bereinigung",
description: "Integritätsprüfung und Aufräumen nach Downloads und Entpacken.",
groups: [
{
id: "cleanup-check",
title: "Prüfung",
fields: [{ id: "enableIntegrityCheck", kind: "switch", label: "Dateien auf Fehler prüfen", value: settings.enableIntegrityCheck }]
},
{
id: "cleanup-extract",
title: "Nach dem Entpacken",
fields: [
{ id: "removeLinkFilesAfterExtract", kind: "switch", label: "Link-Dateien danach entfernen", value: settings.removeLinkFilesAfterExtract },
{ id: "removeSamplesAfterExtract", kind: "switch", label: "Vorschau-Dateien danach entfernen", value: settings.removeSamplesAfterExtract },
{
id: "cleanupMode",
kind: "select",
label: "Archive nach dem Entpacken",
value: settings.cleanupMode,
options: [
{ value: "none", label: "Keine Archive löschen" },
{ value: "trash", label: "Archive in Papierkorb" },
{ value: "delete", label: "Archive löschen" }
]
}
]
},
{
id: "cleanup-finished",
title: "Fertige Downloads und Konflikte",
fields: [
{
id: "completedCleanupPolicy",
kind: "select",
label: "Fertige Downloads aus der Liste",
value: settings.completedCleanupPolicy,
options: [
{ value: "never", label: "Nie" },
{ value: "immediate", label: "Sofort" },
{ value: "on_start", label: "Beim App-Start" },
{ value: "package_done", label: "Sobald Paket fertig ist" }
]
},
{
id: "extractConflictMode",
kind: "select",
label: "Bei gleichnamigen Dateien",
value: settings.extractConflictMode,
options: [
{ value: "overwrite", label: "Überschreiben" },
{ value: "skip", label: "Überspringen" },
{ value: "rename", label: "Umbenennen" },
{ value: "ask", label: "Nachfragen" }
]
}
]
}
]
};
}
if (section === "updates") {
return {
title: "Updates",
description: "Quelle und Zeitpunkt der Update-Prüfung.",
groups: [
{
id: "updates-main",
title: "Aktualisierung",
fields: [
{ id: "autoUpdateCheck", kind: "switch", label: "Beim Start nach Updates suchen", value: settings.autoUpdateCheck },
{ id: "updateRepo", kind: "text", label: "Update-Quelle", value: settings.updateRepo, help: "Quelle im Format Benutzer/Repository." },
{ id: "update:check", kind: "action", label: "Jetzt nach einer neuen Version suchen", actionLabel: "Nach Updates suchen" }
]
}
]
};
}
if (section === "accounts") {
return { title: "Accounts", description: "Accounts und Verwendungsregeln.", groups: [] };
}
return {
title: "Allgemein",
description: "Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.",
groups: [
{
id: "general-storage",
title: "Speicherort",
fields: [
{ id: "outputDir", kind: "path", label: "Download-Ordner", value: settings.outputDir, actionLabel: "Wählen", help: "Zielordner für heruntergeladene Dateien." },
{ id: "packageName", kind: "text", label: "Paketname (optional)", value: settings.packageName }
]
},
{
id: "general-downloads",
title: "Download-Verhalten",
fields: [
{ id: "maxParallel", kind: "number", label: "Max. gleichzeitige Downloads", value: String(settings.maxParallel), min: 1, max: 50 },
{ id: "retryLimit", kind: "number", label: "Automatische Wiederholungen", value: String(settings.retryLimit), min: 0, max: 99 },
{ id: "autoResumeOnStart", kind: "switch", label: "Beim Start automatisch fortsetzen", value: settings.autoResumeOnStart },
{ id: "clipboardWatch", kind: "switch", label: "Zwischenablage überwachen", value: settings.clipboardWatch }
]
},
{
id: "general-history",
title: "Verlauf",
fields: [
{
id: "historyRetentionMode",
kind: "select",
label: "Verlauf speichern",
value: settings.historyRetentionMode,
options: [
{ value: "never", label: "Nie" },
{ value: "session", label: "Nur aktuelle Session" },
{ value: "permanent", label: "Dauerhaft" }
]
},
{ id: "historyMaxEntries", kind: "number", label: "Maximale Verlauf-Einträge", value: String(settings.historyMaxEntries), min: 50, max: 100000, disabled: settings.historyRetentionMode !== "permanent" },
{ id: "historyMaxAgeDays", kind: "number", label: "Einträge löschen älter als (Tage)", value: String(settings.historyMaxAgeDays), min: 0, max: 3650, disabled: settings.historyRetentionMode !== "permanent" }
]
},
{
id: "general-interface",
title: "Oberfläche und Bedienung",
fields: [
{ id: "collapseNewPackages", kind: "switch", label: "Neue Pakete eingeklappt zeigen", value: settings.collapseNewPackages },
{ id: "autoSortPackagesByProgress", kind: "switch", label: "Nach Fortschritt sortieren", value: settings.autoSortPackagesByProgress },
{ id: "minimizeToTray", kind: "switch", label: "In den Infobereich minimieren", value: settings.minimizeToTray },
{ id: "confirmDeleteSelection", kind: "switch", label: "Vor dem Löschen nachfragen", value: settings.confirmDeleteSelection },
{ id: "backupIncludeDownloads", kind: "switch", label: "Download-Liste mitsichern", value: settings.backupIncludeDownloads },
{ id: "backupIncludeRemoteDiagnostics", kind: "switch", label: "Ferndiagnose-Einstellungen mitsichern", value: settings.backupIncludeRemoteDiagnostics },
{
id: "theme",
kind: "theme",
label: "Theme",
value: themeChoice,
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" }
]
}
]
},
{
id: "general-notifications",
title: "Discord-Benachrichtigungen",
fields: [
{ id: "notifyUrl", kind: "text", label: "Webhook-Adresse", value: settings.notifyUrl, placeholder: "https://discord.com/api/webhooks/…", actionLabel: "Testen" },
{ id: "notifyMention", kind: "text", label: "Discord-Erwähnung (optional)", value: settings.notifyMention },
{ id: "notifyOnPackageCompleted", kind: "switch", label: "Melden, wenn ein Paket fertig ist", value: settings.notifyOnPackageCompleted },
{ id: "notifyOnPackageFailed", kind: "switch", label: "Melden, wenn ein Paket fehlschlägt", value: settings.notifyOnPackageFailed },
{ id: "notifyOnRunFinished", kind: "switch", label: "Melden, wenn alles fertig ist", value: settings.notifyOnRunFinished }
]
}
]
};
}
export function buildAccountRowId(service: AccountService, mode: string, identityId: string): string {
return [service, mode, identityId].map((part) => encodeURIComponent(part)).join("::");
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / (1024 ** unitIndex);
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value)} ${units[unitIndex]}`;
}
function formatTraffic(limitBytes?: number, usageBytes?: number): string {
if (!Number.isFinite(limitBytes) || !limitBytes || limitBytes <= 0) {
return "Unbeschränkt";
}
const safeUsage = Number.isFinite(usageBytes) && usageBytes && usageBytes > 0 ? usageBytes : 0;
return `${formatBytes(Math.max(0, limitBytes - safeUsage))} von ${formatBytes(limitBytes)} übrig`;
}
function formatExpiry(premiumUntilMs: number | null): string {
if (!premiumUntilMs || !Number.isFinite(premiumUntilMs) || premiumUntilMs <= 0) {
return "—";
}
return new Intl.DateTimeFormat("de-DE").format(new Date(premiumUntilMs));
}
function projectStatus(source: AccountRowSource): { tone: AccountStatusTone; text: string } {
if (!source.enabled || source.status.state === "disabled") {
return { tone: "disabled", text: "Deaktiviert" };
}
switch (source.status.state) {
case "premium":
return { tone: "ok", text: source.status.message.trim() || "Premium Account" };
case "free":
return { tone: "free", text: "Free Account" };
case "invalid":
return { tone: "invalid", text: source.status.message.trim() || "Zugang ungültig" };
case "checking":
return { tone: "unknown", text: "Prüft…" };
case "unchecked":
return { tone: "unknown", text: "Noch nicht geprüft" };
}
}
function projectCredential(kind: AccountRowSource["credentialKind"]): string {
if (kind === "api-key") {
return "API-Key";
}
return kind === "password" ? "••••••" : "Geschützter Zugang";
}
export function projectAccountRows(
sources: readonly AccountRowSource[],
selectedIds: readonly string[],
nowMs: number = Date.now()
): AccountRowViewModel[] {
const selected = new Set(selectedIds);
return sources.map((source) => {
const id = buildAccountRowId(source.service, source.mode, source.identityId);
const status = projectStatus(source);
const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs
? source.status.premiumUntilMs
: null;
return {
id,
service: source.service,
hoster: source.hoster,
mode: source.mode,
icon: ACCOUNT_SERVICE_ICONS[source.service],
enabled: source.enabled,
selected: selected.has(id),
status,
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
username: resolveAccountUsername(source.username, source.status.email),
expires: formatExpiry(source.status.premiumUntilMs),
credential: projectCredential(source.credentialKind),
canCheck: source.canCheck,
problem: status.tone === "invalid",
premiumUntilMs
};
});
}
export function sortAccountRows(
rows: readonly AccountRowViewModel[],
direction: "desc" | "asc" = "desc"
): AccountRowViewModel[] {
return rows
.map((row, index) => ({ row, index }))
.sort((left, right) => {
const leftUntil = left.row.premiumUntilMs ?? -1;
const rightUntil = right.row.premiumUntilMs ?? -1;
if (leftUntil > 0 && rightUntil > 0) {
return (direction === "desc" ? rightUntil - leftUntil : leftUntil - rightUntil) || left.index - right.index;
}
if (leftUntil > 0) {
return -1;
}
if (rightUntil > 0) {
return 1;
}
return left.index - right.index;
})
.map(({ row }) => row);
}
export function pruneAccountSelection(
selectedIds: readonly string[],
rows: readonly Pick<AccountRowViewModel, "id">[]
): string[] {
const existing = new Set(rows.map((row) => row.id));
return [...new Set(selectedIds)].filter((id) => existing.has(id));
}
export function filterAccountAddOptions(
options: readonly AccountAddOption[],
query: string,
filter: AccountAddFilter,
configuredServices: readonly string[]
): AccountAddOption[] {
const normalizedQuery = query.trim().toLocaleLowerCase("de-DE");
const configured = new Set(configuredServices);
return options.filter((option) => {
if (!option.multi && configured.has(option.service)) {
return false;
}
if (filter !== "all" && option.filter !== filter) {
return false;
}
if (!normalizedQuery) {
return true;
}
return [option.title, option.service, option.mode, option.description, option.functionLabel]
.join(" ")
.toLocaleLowerCase("de-DE")
.includes(normalizedQuery);
});
}
export function reconcileAccountAddDraft(
draft: AccountAddDraft,
visibleOptions: readonly Pick<AccountAddOption, "id">[]
): AccountAddDraft {
if (draft.selectedId && visibleOptions.some((option) => option.id === draft.selectedId)) {
return draft;
}
return {
selectedId: null,
login: "",
password: "",
token: "",
dailyLimitGb: ""
};
}
export function buildTargetedAccountCheck(
option: Pick<AccountAddOption, "service">,
identityId: string
): TargetedAccountCheck | null {
if (option.service !== "megadebrid-api" && option.service !== "debridlink") {
return null;
}
return { service: option.service, expectedStatusId: identityId };
}
+876
View File
@@ -0,0 +1,876 @@
.settings-view {
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
}
.settings-sidebar {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 8px;
padding: 12px;
border-right: 1px solid var(--ui-border);
background: var(--ui-surface);
}
.settings-sidebar-heading {
display: flex;
min-height: 32px;
align-items: center;
padding: 0 8px;
color: var(--ui-text-secondary);
font-size: 13px;
}
.settings-sidebar-list {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.settings-sidebar-item {
min-height: 36px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-sidebar-item:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.settings-sidebar-item.is-active {
border-color: var(--ui-border);
background: var(--ui-active);
color: var(--ui-text);
}
.settings-content {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 20px;
padding: 24px;
overflow-y: auto;
background: var(--ui-canvas);
}
.md-runtime-view-content > .settings-content {
height: 100%;
padding: 24px;
}
.settings-static {
user-select: none;
}
.settings-content :where(input, textarea, [contenteditable="true"], .settings-copyable) {
user-select: text;
}
.settings-content-header {
display: flex;
min-width: 0;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.settings-content-header > div {
display: flex;
min-width: 0;
align-items: baseline;
gap: 12px;
}
.settings-content-header h1,
.settings-form-heading h2,
.settings-account-heading h2,
.settings-form-group h3,
.settings-rule-section h3 {
margin: 0;
color: var(--ui-text);
}
.settings-content-header h1 {
font-size: 22px;
line-height: 30px;
}
.settings-save-state {
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-save-state.is-dirty,
.settings-save-state.is-saving {
color: var(--ui-warning);
}
.settings-save-state.is-error {
color: var(--ui-danger);
}
.settings-content-body {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
align-items: flex-start;
}
.settings-content-body.is-accounts {
align-items: stretch;
}
.settings-form-column {
width: 500px;
max-width: 100%;
}
.settings-form-heading {
margin-bottom: 24px;
}
.settings-form-heading h2,
.settings-account-heading h2 {
font-size: 20px;
line-height: 28px;
}
.settings-form-heading p,
.settings-account-heading p,
.settings-form-group > header p,
.settings-rule-section > p {
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 13px;
line-height: 20px;
}
.settings-form-group {
padding: 22px 0;
border-top: 1px solid var(--ui-border);
}
.settings-form-group:first-of-type {
border-top: 0;
padding-top: 0;
}
.settings-form-group h3,
.settings-rule-section h3 {
font-size: 15px;
line-height: 22px;
}
.settings-form-fields {
display: grid;
gap: 18px;
margin-top: 16px;
}
.settings-field {
display: grid;
gap: 7px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.settings-field > label,
.settings-field > legend,
.settings-account-dialog-field > span,
.settings-switch-label {
color: var(--ui-text-secondary);
font-size: 13px;
font-weight: 600;
line-height: 18px;
}
.settings-control {
width: 100%;
height: 44px;
min-width: 0;
padding: 0 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
outline: 0;
background: var(--ui-input);
color: var(--ui-text);
color-scheme: inherit;
font: inherit;
}
.settings-control:focus-visible,
.settings-button:focus-visible,
.settings-switch:focus-visible,
.settings-theme-option:focus-visible,
.settings-sidebar-item:focus-visible,
.settings-account-tabs button:focus-visible,
.settings-account-row:focus-visible,
.settings-account-action-button:focus-visible,
.settings-account-picker-row:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.settings-control:disabled,
.settings-button:disabled,
.settings-switch:disabled {
cursor: default;
opacity: 0.5;
}
.settings-textarea {
height: auto;
min-height: 108px;
padding: 10px 12px;
line-height: 1.45;
resize: vertical;
}
.settings-control-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.settings-field-help {
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.settings-switch-field,
.settings-action-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-switch-field > div,
.settings-action-field > div {
display: grid;
min-width: 0;
gap: 3px;
}
.settings-switch {
position: relative;
width: 40px;
height: 20px;
flex: 0 0 40px;
padding: 0;
border: 1px solid var(--ui-border);
border-radius: 10px;
background: var(--ui-input);
}
.settings-switch > span {
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--ui-text-muted);
transition: transform 120ms ease, background 120ms ease;
}
.settings-switch.is-on {
border-color: var(--ui-accent);
background: var(--ui-accent);
}
.settings-switch.is-on > span {
background: #FFFFFF;
transform: translateX(20px);
}
.settings-theme-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.settings-theme-option {
display: grid;
gap: 7px;
padding: 8px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-theme-option.is-active {
border-color: var(--ui-accent);
color: var(--ui-text);
}
.settings-theme-preview {
position: relative;
display: block;
height: 42px;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 4px;
}
.settings-theme-preview::after {
position: absolute;
top: 7px;
left: 8px;
width: 58%;
height: 4px;
border-radius: 2px;
background: #9AA1AD;
box-shadow: 0 9px 0 #737B88, 0 18px 0 #737B88;
content: "";
}
.settings-theme-preview.is-light {
background: #F3F4F6;
}
.settings-theme-preview.is-dark {
background: #1C1D20;
}
.settings-theme-preview.is-system {
background: #F3F4F6;
}
.settings-theme-preview.is-system::before {
position: absolute;
inset: 0 0 0 50%;
background: #1C1D20;
content: "";
}
.settings-button {
height: 36px;
padding: 0 13px;
border: 1px solid var(--ui-border);
border-radius: 6px;
color: var(--ui-text-secondary);
font: inherit;
white-space: nowrap;
}
.settings-button-secondary {
background: var(--ui-modal-secondary);
}
.settings-button-primary {
border-color: var(--ui-primary);
background: var(--ui-primary);
color: #181A1F;
}
.settings-button-danger {
border-color: color-mix(in srgb, var(--ui-danger) 70%, var(--ui-border));
background: transparent;
color: var(--ui-danger);
}
.settings-button:hover:not(:disabled),
.settings-account-tabs button:hover,
.settings-account-action-button:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.settings-button-primary:hover:not(:disabled) {
background: var(--ui-primary-hover);
color: #181A1F;
}
.settings-account-workspace {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
width: 100%;
min-width: 0;
min-height: 0;
gap: 14px;
}
.settings-account-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-account-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--ui-border);
}
.settings-account-tabs button {
min-height: 36px;
padding: 0 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--ui-text-muted);
font: inherit;
}
.settings-account-tabs button[aria-selected="true"] {
border-bottom-color: var(--ui-accent);
color: var(--ui-text);
}
.settings-account-panel {
display: flex;
width: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.settings-account-panel[hidden] {
display: none;
}
.settings-account-table {
min-height: 280px;
}
.settings-account-table-header {
height: 41px;
overflow: hidden;
}
.settings-account-table-grid,
.settings-account-row {
display: grid;
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(190px, 1.15fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
min-width: 1110px;
align-items: center;
}
.settings-account-table-grid {
height: 41px;
color: var(--ui-text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.settings-account-table-grid > span,
.settings-account-row > span {
min-width: 0;
padding: 0 11px;
}
.settings-account-table-body {
overflow: auto;
}
.settings-account-row {
position: relative;
height: 48px;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 14px;
}
.settings-account-row::before {
position: absolute;
top: 5px;
bottom: 5px;
left: 0;
width: 3px;
border-radius: 0 2px 2px 0;
background: var(--ui-accent);
content: "";
opacity: 0;
}
.settings-account-row:hover {
background: var(--ui-hover);
}
.settings-account-row.is-selected {
background: var(--ui-active);
}
.settings-account-row.is-selected::before {
opacity: 1;
}
.settings-account-row.is-disabled {
color: var(--ui-text-muted);
opacity: 0.72;
}
.settings-account-column-enable,
.settings-account-column-actions {
display: grid;
place-items: center;
}
.settings-account-hoster {
display: flex;
align-items: center;
gap: 9px;
}
.settings-account-hoster img {
flex: 0 0 20px;
object-fit: contain;
}
.settings-account-hoster > span {
display: grid;
min-width: 0;
}
.settings-account-hoster strong,
.settings-account-hoster small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-hoster small {
color: var(--ui-text-muted);
font-size: 12px;
font-weight: 400;
}
.settings-account-status-badge {
display: inline-flex;
max-width: 100%;
align-items: center;
gap: 6px;
overflow: hidden;
color: var(--ui-text-secondary);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-status-badge::before {
width: 7px;
height: 7px;
flex: 0 0 7px;
border-radius: 50%;
background: var(--ui-text-muted);
content: "";
}
.settings-account-status-badge.is-ok::before {
background: var(--ui-primary);
}
.settings-account-status-badge.is-free::before,
.settings-account-status-badge.is-unknown::before {
background: var(--ui-warning);
}
.settings-account-status-badge.is-invalid::before {
background: var(--ui-danger);
}
.settings-account-status-badge.is-disabled::before {
background: var(--ui-text-muted);
}
.settings-account-traffic,
.settings-account-expires,
.settings-account-credential {
color: var(--ui-text-muted);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-username {
overflow: hidden;
color: var(--ui-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-action-button {
display: grid;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
font-size: 20px;
}
.settings-account-local-actions {
display: flex;
min-height: 52px;
flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: 12px;
border-top: 1px solid var(--ui-border);
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-account-local-actions > div {
display: flex;
gap: 8px;
}
.settings-account-table-error .ui-data-table-empty-title,
.settings-account-dialog-error {
color: var(--ui-danger);
}
.settings-account-rules {
display: grid;
width: 100%;
min-width: 0;
gap: 22px;
overflow-y: auto;
}
.settings-rule-section {
display: grid;
gap: 12px;
padding-bottom: 22px;
border-bottom: 1px solid var(--ui-border);
}
.settings-provider-order,
.settings-routing-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.settings-provider-order li,
.settings-routing-list li,
.settings-rotation-event {
display: flex;
min-height: 40px;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 7px 10px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
}
.settings-provider-order-actions {
display: flex;
gap: 4px;
}
.settings-provider-order-actions button {
width: 30px;
height: 28px;
border: 1px solid var(--ui-border);
border-radius: 5px;
background: var(--ui-modal-secondary);
color: var(--ui-text-secondary);
}
.settings-rule-toggle {
display: flex;
align-items: center;
gap: 9px;
color: var(--ui-text-secondary);
font-size: 13px;
}
.settings-rule-empty {
color: var(--ui-text-muted);
font-size: 13px;
}
.settings-rotation-event {
align-items: flex-start;
flex-direction: column;
}
.settings-rotation-event span {
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-account-dialog-body {
display: grid;
gap: 16px;
min-height: 0;
}
.settings-account-picker-controls {
display: grid;
grid-template-columns: minmax(0, 1fr) 128px;
gap: 8px;
}
.settings-account-picker {
display: grid;
max-height: 430px;
min-height: 0;
gap: 6px;
overflow-y: auto;
}
.settings-account-picker-entry {
display: grid;
gap: 10px;
}
.settings-account-picker-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 150px;
gap: 12px;
width: 100%;
min-height: 52px;
padding: 8px 10px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
font: inherit;
text-align: left;
}
.settings-account-picker-row.is-selected {
border-color: var(--ui-accent);
background: var(--ui-active);
}
.settings-account-picker-row > span {
display: grid;
min-width: 0;
gap: 2px;
}
.settings-account-picker-row small {
overflow: hidden;
color: var(--ui-text-muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-account-picker-empty {
padding: 24px;
color: var(--ui-text-muted);
text-align: center;
}
.settings-account-dialog-fields {
display: grid;
gap: 14px;
padding: 2px 10px 14px;
}
.settings-account-dialog-field {
display: grid;
gap: 7px;
}
.settings-account-dialog-textarea {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.settings-account-dialog-error {
margin: 0;
font-size: 13px;
}
.settings-account-dialog-actions {
justify-content: flex-start;
}
.settings-account-dialog-action-spacer {
flex: 1 1 auto;
}
.settings-account-edit-identity {
display: grid;
gap: 4px;
padding-bottom: 14px;
border-bottom: 1px solid var(--ui-border);
}
.settings-account-edit-identity span {
color: var(--ui-text-muted);
font-size: 12px;
}
.settings-account-edit-identity strong {
color: var(--ui-text);
overflow-wrap: anywhere;
}
.settings-account-edit-enabled {
min-height: 30px;
}
@media (max-width: 1366px) {
.settings-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.settings-sidebar {
overflow: hidden;
}
.settings-content {
padding: 20px;
}
.settings-account-table-grid,
.settings-account-row {
grid-template-columns: 40px 160px 140px 180px 180px 120px 140px 42px;
min-width: 1002px;
}
}
@media (max-width: 1120px) {
.settings-content {
padding: 16px;
}
.settings-theme-options,
.settings-account-picker-controls,
.settings-account-picker-row {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,226 @@
import type { ReactElement, ReactNode } from "react";
import type {
StatisticsMetric,
StatisticsProviderScope,
StatisticsRange,
StatisticsViewModel
} from "./statistics-model";
import "./statistics.css";
export interface StatisticsViewActions {
onRangeChange: (range: StatisticsRange) => void;
onResetSession: () => void;
onResetAll: () => void;
onResetErrors: () => void;
}
export interface StatisticsViewProps {
model: StatisticsViewModel;
actions: StatisticsViewActions;
chart: ReactNode;
}
const rangeItems: Array<{ id: StatisticsRange; label: string }> = [
{ id: "session", label: "Sitzung" },
{ id: "today", label: "Heute" },
{ id: "week", label: "Sieben Tage" },
{ id: "month", label: "30 Tage" },
{ id: "all", label: "Gesamt" }
];
const numberFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 });
function formatBytes(bytes: number): string {
const safe = Math.max(0, Number.isFinite(bytes) ? bytes : 0);
if (safe < 1024) {
return `${Math.round(safe)} B`;
}
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = safe / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${numberFormatter.format(value)} ${units[unitIndex]}`;
}
function formatMetric(metric: StatisticsMetric, kind: "bytes" | "count" | "percent" | "speed"): string {
if (!metric.available || metric.value === null) {
return "";
}
if (kind === "bytes") {
return formatBytes(metric.value);
}
if (kind === "speed") {
return `${formatBytes(metric.value)}/s`;
}
if (kind === "percent") {
return `${numberFormatter.format(metric.value)} %`;
}
return numberFormatter.format(metric.value);
}
function providerScopeLabel(scope: StatisticsProviderScope | null): string {
if (scope === "current-queue") {
return "Aktuelle Queue";
}
if (scope === "today") {
return "Heute";
}
if (scope === "all") {
return "Gesamt";
}
return "Nicht verfügbar";
}
function emptyProviderMessage(model: StatisticsViewModel): string {
if (model.coverage === "unavailable") {
return model.message;
}
if (model.providerScope === "today") {
return "Heute wurden noch keine Providerbytes erfasst.";
}
if (model.providerScope === "all") {
return "Noch keine gespeicherten Providerbytes vorhanden.";
}
return "In der aktuellen Queue sind noch keine Providerwerte vorhanden.";
}
function StatisticsMetricCard({
label,
metric,
kind
}: {
label: string;
metric: StatisticsMetric;
kind: "bytes" | "count" | "percent" | "speed";
}): ReactElement {
return (
<article className={`statistics-kpi${metric.tone === "danger" ? " statistics-kpi-danger" : ""}`}>
<span className="statistics-kpi-label">{label}</span>
<strong className="statistics-kpi-value">{formatMetric(metric, kind)}</strong>
<span className="statistics-kpi-source">{metric.sourceLabel}</span>
</article>
);
}
export function StatisticsSidebar({ model, actions }: Pick<StatisticsViewProps, "model" | "actions">): ReactElement {
return (
<nav aria-label="Statistik-Zeitraum" className="statistics-sidebar" data-visual-region="statistics-sidebar">
<strong className="statistics-sidebar-heading">Zeitraum</strong>
<div className="statistics-range-list">
{rangeItems.map((item) => (
<button
aria-current={model.range === item.id ? "page" : undefined}
className={`statistics-range${model.range === item.id ? " statistics-range-active" : ""}`}
key={item.id}
onClick={() => actions.onRangeChange(item.id)}
type="button"
>{item.label}</button>
))}
</div>
<p className="statistics-sidebar-message">{model.message}</p>
</nav>
);
}
export function StatisticsSidebarStatus({ model }: Pick<StatisticsViewProps, "model">): ReactElement | null {
const metrics = model.metrics;
const rows = [
metrics.downloadedBytes.available ? `Daten: ${formatMetric(metrics.downloadedBytes, "bytes")}` : null,
metrics.files.available ? `Dateien: ${formatMetric(metrics.files, "count")}` : null,
metrics.successRate.available ? `Erfolg: ${formatMetric(metrics.successRate, "percent")}` : null,
metrics.errors.available ? `Fehler: ${formatMetric(metrics.errors, "count")}` : null,
model.providerScope ? `Provider: ${model.providers.length}` : null
].filter((value): value is string => value !== null);
if (rows.length === 0) {
return null;
}
return (
<div className="statistics-sidebar-status">
{rows.map((row) => <span key={row}>{row}</span>)}
</div>
);
}
export function StatisticsContent({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<section aria-label="Statistik-Dashboard" className="statistics-content">
<header className="statistics-heading">
<div>
<h2>Statistiken</h2>
<p>{model.message}</p>
</div>
<div aria-label="Statistiken zurücksetzen" className="statistics-reset-actions">
<button className="statistics-reset" onClick={actions.onResetSession} type="button">Sitzung zurücksetzen</button>
<button className="statistics-reset" onClick={actions.onResetAll} type="button">Gesamt zurücksetzen</button>
<button
className="statistics-reset statistics-reset-danger"
disabled={!model.errorResetAvailable}
onClick={actions.onResetErrors}
type="button"
>Fehler zurücksetzen</button>
</div>
</header>
<div className="statistics-kpis" data-visual-region="statistics-kpis">
<StatisticsMetricCard kind="bytes" label="Datenmenge" metric={model.metrics.downloadedBytes} />
<StatisticsMetricCard kind="count" label="Dateien" metric={model.metrics.files} />
<StatisticsMetricCard kind="percent" label="Erfolgsquote" metric={model.metrics.successRate} />
<StatisticsMetricCard kind="speed" label="Durchschnitt" metric={model.metrics.averageSpeedBps} />
<StatisticsMetricCard kind="count" label="Fehler" metric={model.metrics.errors} />
</div>
<div className="statistics-detail-grid">
<section className="statistics-chart" data-visual-region="statistics-chart">
<div className="statistics-section-heading">
<h3>Bandbreitenverlauf</h3>
<span>Live aus der aktuellen Renderer-Sitzung</span>
</div>
<div className="statistics-chart-canvas">{chart}</div>
</section>
<section className="statistics-providers">
<div className="statistics-section-heading">
<h3>Provider</h3>
<span>{providerScopeLabel(model.providerScope)}</span>
</div>
<div aria-label="Provider-Nutzung" className="statistics-provider-table" role="table">
<div className="statistics-provider-header" role="row">
<span role="columnheader">Provider</span>
<span role="columnheader">Daten</span>
<span role="columnheader">Ergebnisse</span>
</div>
<div className="statistics-provider-body" role="rowgroup">
{model.providers.length > 0 ? model.providers.map((provider) => (
<div className="statistics-provider-row" key={provider.id} role="row">
<span className="statistics-provider-name" role="cell">{provider.label}</span>
<span role="cell">{formatBytes(provider.bytes)}</span>
<span className={provider.failed && provider.failed > 0 ? "statistics-provider-errors" : undefined} role="cell">
{provider.completed === null || provider.failed === null
? ""
: `${provider.completed} fertig · ${provider.failed} Fehler`}
</span>
</div>
)) : (
<div className="statistics-provider-empty" role="row">
<span aria-colspan={3} role="cell">{emptyProviderMessage(model)}</span>
</div>
)}
</div>
</div>
</section>
</div>
</section>
);
}
export function StatisticsView({ model, actions, chart }: StatisticsViewProps): ReactElement {
return (
<div className="statistics-composed-view">
<StatisticsSidebar actions={actions} model={model} />
<StatisticsContent actions={actions} chart={chart} model={model} />
</div>
);
}
@@ -0,0 +1,280 @@
import { getProviderUsageDayKey } from "../../../shared/provider-daily-limits";
import type { DebridProvider, DownloadItem, DownloadSummary, UiSnapshot } from "../../../shared/types";
export type StatisticsRange = "session" | "today" | "week" | "month" | "all";
export type StatisticsCoverage = "partial" | "unavailable";
export type StatisticsSessionState = "empty" | "idle" | "active" | "paused";
export type StatisticsProviderScope = "current-queue" | "today" | "all";
export type StatisticsMetricTone = "danger";
export interface StatisticsMetric {
value: number | null;
available: boolean;
sourceLabel: string;
tone?: StatisticsMetricTone;
}
export interface StatisticsProviderRow {
id: DebridProvider;
label: string;
bytes: number;
completed: number | null;
failed: number | null;
}
export interface StatisticsMetrics {
downloadedBytes: StatisticsMetric;
files: StatisticsMetric;
successRate: StatisticsMetric;
averageSpeedBps: StatisticsMetric;
errors: StatisticsMetric;
}
export interface StatisticsViewModel {
range: StatisticsRange;
coverage: StatisticsCoverage;
message: string;
sessionState: StatisticsSessionState;
metrics: StatisticsMetrics;
providerScope: StatisticsProviderScope | null;
providers: StatisticsProviderRow[];
errorResetAvailable: boolean;
}
const providerLabels: Record<DebridProvider, string> = {
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 historicalUnavailableMessage = "Für diesen Zeitraum werden noch keine historischen Daten gespeichert.";
function normalizeNonNegative(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
function normalizeCount(value: number): number {
return Math.floor(normalizeNonNegative(value));
}
function availableMetric(value: number, sourceLabel: string, tone?: StatisticsMetricTone): StatisticsMetric {
return {
value: normalizeNonNegative(value),
available: true,
sourceLabel,
...(tone ? { tone } : {})
};
}
function unavailableMetric(sourceLabel: string): StatisticsMetric {
return {
value: null,
available: false,
sourceLabel
};
}
function sortProviderRows(rows: StatisticsProviderRow[]): StatisticsProviderRow[] {
return rows.sort((left, right) => right.bytes - left.bytes || left.id.localeCompare(right.id));
}
function deriveSessionState(snapshot: UiSnapshot): StatisticsSessionState {
if (snapshot.session.paused) {
return "paused";
}
if (snapshot.session.running) {
return "active";
}
const hasItems = Object.keys(snapshot.session.items).length > 0;
const summary = snapshot.summary;
const hasSummary = Boolean(summary && normalizeCount(summary.success) + normalizeCount(summary.failed) + normalizeCount(summary.cancelled) > 0);
return hasItems || hasSummary ? "idle" : "empty";
}
function countQueueResults(items: DownloadItem[]): { completed: number; failed: number } {
let completed = 0;
let failed = 0;
for (const item of items) {
if (item.status === "completed") {
completed += 1;
} else if (item.status === "failed") {
failed += 1;
}
}
return { completed, failed };
}
function countSummaryResults(summary: DownloadSummary): { completed: number; failed: number } {
return {
completed: normalizeCount(summary.success),
failed: normalizeCount(summary.failed)
};
}
function successRateMetric(completed: number, failed: number, sourceLabel: string): StatisticsMetric {
const terminal = completed + failed;
return terminal > 0
? availableMetric((completed / terminal) * 100, sourceLabel)
: unavailableMetric("Keine abgeschlossenen oder fehlgeschlagenen Ergebnisse");
}
function deriveQueueProviders(items: DownloadItem[]): StatisticsProviderRow[] {
const providers = new Map<DebridProvider, StatisticsProviderRow>();
for (const item of items) {
if (!item.provider) {
continue;
}
const existing = providers.get(item.provider);
const row = existing ?? {
id: item.provider,
label: item.providerLabel?.trim() || providerLabels[item.provider],
bytes: 0,
completed: 0,
failed: 0
};
if (!existing && item.providerLabel?.trim()) {
row.label = item.providerLabel.trim();
}
row.bytes += normalizeNonNegative(item.downloadedBytes);
if (item.status === "completed") {
row.completed = (row.completed ?? 0) + 1;
} else if (item.status === "failed") {
row.failed = (row.failed ?? 0) + 1;
}
providers.set(item.provider, row);
}
return sortProviderRows([...providers.values()]);
}
function deriveUsageProviders(usage: Partial<Record<DebridProvider, number>>): StatisticsProviderRow[] {
const rows: StatisticsProviderRow[] = [];
for (const [id, rawBytes] of Object.entries(usage) as Array<[DebridProvider, number | undefined]>) {
const bytes = normalizeNonNegative(rawBytes ?? 0);
if (bytes <= 0 || !providerLabels[id]) {
continue;
}
rows.push({
id,
label: providerLabels[id],
bytes,
completed: null,
failed: null
});
}
return sortProviderRows(rows);
}
function sumProviderBytes(rows: StatisticsProviderRow[]): number {
return rows.reduce((total, row) => total + row.bytes, 0);
}
function buildUnavailableMetrics(): StatisticsMetrics {
return {
downloadedBytes: unavailableMetric(historicalUnavailableMessage),
files: unavailableMetric(historicalUnavailableMessage),
successRate: unavailableMetric(historicalUnavailableMessage),
averageSpeedBps: unavailableMetric(historicalUnavailableMessage),
errors: unavailableMetric(historicalUnavailableMessage)
};
}
export function buildStatisticsViewModel(
snapshot: UiSnapshot,
range: StatisticsRange,
nowMs = Date.now()
): StatisticsViewModel {
const sessionState = deriveSessionState(snapshot);
if (range === "week" || range === "month") {
return {
range,
coverage: "unavailable",
message: historicalUnavailableMessage,
sessionState,
metrics: buildUnavailableMetrics(),
providerScope: null,
providers: [],
errorResetAvailable: false
};
}
if (range === "today") {
const isCurrentDay = snapshot.settings.providerDailyUsageDay === getProviderUsageDayKey(nowMs);
const providers = isCurrentDay ? deriveUsageProviders(snapshot.settings.providerDailyUsageBytes) : [];
return {
range,
coverage: "partial",
message: "Heutige Daten stammen aus den lokalen Provider-Nutzungszählern des aktuellen Kalendertags.",
sessionState,
metrics: {
downloadedBytes: availableMetric(sumProviderBytes(providers), "Provider-Nutzung heute"),
files: unavailableMetric("Dateianzahlen werden nicht tagesweise gespeichert"),
successRate: unavailableMetric("Ergebnisse werden nicht tagesweise gespeichert"),
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht tagesweise gespeichert"),
errors: unavailableMetric("Fehler werden nicht tagesweise gespeichert")
},
providerScope: "today",
providers,
errorResetAvailable: false
};
}
if (range === "all") {
const providers = deriveUsageProviders(snapshot.settings.providerTotalUsageBytes);
return {
range,
coverage: "partial",
message: "Gesamtwerte stammen aus dauerhaft gespeicherten Zählern. Ergebnisse und Geschwindigkeiten werden nicht historisch gespeichert.",
sessionState,
metrics: {
downloadedBytes: availableMetric(snapshot.stats.totalDownloadedAllTime, "Gesamtzähler"),
files: availableMetric(snapshot.stats.totalFilesAllTime, "Gesamtzähler"),
successRate: unavailableMetric("Ergebnisse werden nicht dauerhaft gespeichert"),
averageSpeedBps: unavailableMetric("Durchschnittsgeschwindigkeit wird nicht dauerhaft gespeichert"),
errors: unavailableMetric("Fehler werden nicht dauerhaft gespeichert")
},
providerScope: "all",
providers,
errorResetAvailable: false
};
}
const items = Object.values(snapshot.session.items);
const queueResults = countQueueResults(items);
const runInProgress = snapshot.session.running || snapshot.session.paused;
const useSummary = !runInProgress && snapshot.summary !== null;
const results = useSummary ? countSummaryResults(snapshot.summary as DownloadSummary) : queueResults;
const resultSource = useSummary ? "Letzter beendeter Lauf" : "Aktuelle Queue";
const failed = results.failed;
const summaryAverage = useSummary && results.completed + results.failed > 0
? availableMetric((snapshot.summary as DownloadSummary).averageSpeedBps, "Letzter beendeter Lauf")
: unavailableMetric(runInProgress
? "Während des laufenden Durchgangs nicht als Gesamtwert verfügbar"
: "Kein beendeter Lauf mit Ergebnissen verfügbar");
return {
range,
coverage: "partial",
message: useSummary
? "Sitzungszähler und Ergebnisse des zuletzt beendeten Laufs werden angezeigt."
: "Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.",
sessionState,
metrics: {
downloadedBytes: availableMetric(snapshot.stats.totalDownloaded, "Sitzungszähler"),
files: availableMetric(snapshot.stats.totalFilesSession, "Sitzungszähler"),
successRate: successRateMetric(results.completed, failed, resultSource),
averageSpeedBps: summaryAverage,
errors: availableMetric(failed, resultSource, failed > 0 ? "danger" : undefined)
},
providerScope: "current-queue",
providers: deriveQueueProviders(items),
errorResetAvailable: queueResults.failed > 0
};
}
@@ -0,0 +1,370 @@
.statistics-sidebar,
.statistics-content,
.statistics-sidebar-status {
user-select: none;
}
.statistics-sidebar {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.statistics-sidebar-heading {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
text-transform: uppercase;
}
.statistics-range-list {
display: grid;
gap: 4px;
}
.statistics-range {
display: flex;
width: 100%;
min-height: 36px;
padding: 8px 10px;
align-items: center;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
text-align: left;
}
.statistics-range:hover {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-range-active {
border-color: var(--ui-border);
background: var(--ui-active);
color: var(--ui-text);
}
.statistics-range:focus-visible,
.statistics-reset:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.statistics-sidebar-message {
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-sidebar-status {
display: contents;
}
.statistics-content {
display: grid;
min-width: 0;
min-height: 100%;
align-content: start;
gap: 16px;
padding: 20px 24px 24px;
overflow: auto;
}
.statistics-heading {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.statistics-heading h2,
.statistics-section-heading h3 {
margin: 0;
color: var(--ui-text);
}
.statistics-heading h2 {
font-size: 20px;
font-weight: 600;
line-height: 28px;
}
.statistics-heading p {
max-width: 760px;
margin: 4px 0 0;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 18px;
}
.statistics-reset-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.statistics-reset {
min-height: 36px;
padding: 7px 12px;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
color: var(--ui-text-secondary);
cursor: pointer;
font: inherit;
font-weight: 500;
}
.statistics-reset:hover:not(:disabled) {
background: var(--ui-hover);
color: var(--ui-text);
}
.statistics-reset-danger {
color: var(--ui-danger);
}
.statistics-reset:disabled {
color: var(--ui-text-muted);
cursor: not-allowed;
opacity: 0.6;
}
.statistics-kpis {
display: grid;
min-width: 0;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
}
.statistics-kpi {
display: flex;
min-width: 0;
min-height: 116px;
padding: 14px 16px;
flex-direction: column;
justify-content: space-between;
gap: 8px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-kpi-label {
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-kpi-value {
min-width: 0;
color: var(--ui-text);
font-size: clamp(20px, 1.6vw, 28px);
font-weight: 600;
line-height: 1.2;
white-space: nowrap;
}
.statistics-kpi-danger .statistics-kpi-value {
color: var(--ui-danger);
}
.statistics-kpi-source {
min-height: 32px;
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
overflow-wrap: anywhere;
}
.statistics-detail-grid {
display: grid;
min-width: 0;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, 0.75fr);
gap: 16px;
}
.statistics-chart,
.statistics-providers {
display: flex;
min-width: 0;
min-height: 340px;
padding: 16px;
flex-direction: column;
gap: 12px;
border: 1px solid var(--ui-border);
border-radius: 8px;
background: var(--ui-surface);
}
.statistics-section-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.statistics-section-heading h3 {
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
.statistics-section-heading span {
color: var(--ui-text-muted);
font-size: 12px;
line-height: 16px;
text-align: right;
}
.statistics-chart-canvas {
min-width: 0;
min-height: 280px;
flex: 1 1 auto;
}
.statistics-chart-canvas .bandwidth-chart-container {
height: 100%;
min-height: 280px;
margin: 0;
border: 1px solid var(--ui-border);
border-radius: 6px;
background: var(--ui-input);
}
.statistics-provider-table {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--ui-border);
border-radius: 6px;
}
.statistics-provider-header,
.statistics-provider-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(120px, 1fr) minmax(88px, 0.65fr) minmax(130px, 1fr);
align-items: center;
gap: 12px;
}
.statistics-provider-header {
min-height: 41px;
padding: 0 12px;
border-bottom: 1px solid var(--ui-border);
background: var(--ui-table-header);
color: var(--ui-text-secondary);
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.statistics-provider-body {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
.statistics-provider-row {
min-height: 48px;
padding: 8px 12px;
border-bottom: 1px solid var(--ui-border);
color: var(--ui-text-secondary);
font-size: 13px;
line-height: 18px;
}
.statistics-provider-row:last-child {
border-bottom: 0;
}
.statistics-provider-row:hover {
background: var(--ui-hover);
}
.statistics-provider-row > span {
min-width: 0;
overflow-wrap: anywhere;
}
.statistics-provider-name {
color: var(--ui-text);
font-weight: 600;
}
.statistics-provider-errors {
color: var(--ui-danger);
}
.statistics-provider-empty {
display: grid;
min-height: 180px;
padding: 24px;
place-items: center;
color: var(--ui-text-muted);
font-size: 13px;
line-height: 20px;
text-align: center;
}
.statistics-composed-view {
display: grid;
min-width: 0;
min-height: 0;
grid-template-columns: 270px minmax(0, 1fr);
gap: 8px;
overflow: hidden;
}
@media (max-width: 1500px) {
.statistics-composed-view {
grid-template-columns: 56px minmax(0, 1fr);
}
.statistics-sidebar {
overflow: hidden;
}
.statistics-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.statistics-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 1120px) {
.statistics-content {
padding: 16px;
}
.statistics-heading {
flex-direction: column;
}
.statistics-reset-actions {
justify-content: flex-start;
}
.statistics-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+1
View File
@@ -71,6 +71,7 @@ export const IPC_CHANNELS = {
GET_HISTORY: "history:get", GET_HISTORY: "history:get",
CLEAR_HISTORY: "history:clear", CLEAR_HISTORY: "history:clear",
REMOVE_HISTORY_ENTRY: "history:remove-entry", REMOVE_HISTORY_ENTRY: "history:remove-entry",
REVEAL_HISTORY_ENTRY: "history:reveal-entry",
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items", RESET_ITEMS: "queue:reset-items",
+2
View File
@@ -9,6 +9,7 @@ import type {
DuplicatePolicy, DuplicatePolicy,
EnableRemoteDiagnosticsInput, EnableRemoteDiagnosticsInput,
HistoryEntry, HistoryEntry,
HistoryRevealResult,
PackagePriority, PackagePriority,
RemoteDiagnosticsInfo, RemoteDiagnosticsInfo,
RendererErrorReport, RendererErrorReport,
@@ -92,6 +93,7 @@ export interface ElectronApi {
getHistory: () => Promise<HistoryEntry[]>; getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>; clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>; removeHistoryEntry: (entryId: string) => Promise<void>;
revealHistoryEntry: (entryId: string) => Promise<HistoryRevealResult>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>; setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>; resetItems: (itemIds: string[]) => Promise<void>;
+11
View File
@@ -529,6 +529,17 @@ export interface HistoryState {
maxEntries: number; maxEntries: number;
} }
export type HistoryRevealFailureReason =
| "entry-not-found"
| "invalid-output-dir"
| "output-dir-missing"
| "output-dir-not-directory"
| "open-failed";
export type HistoryRevealResult =
| { ok: true }
| { ok: false; reason: HistoryRevealFailureReason };
export interface RendererErrorReport { export interface RendererErrorReport {
kind: "error" | "unhandledrejection" | "react"; kind: "error" | "unhandledrejection" | "react";
message: string; message: string;
+19 -7
View File
@@ -1,15 +1,27 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const workspaceSource = readFileSync(
const styles = readFileSync(new URL("../src/renderer/styles.css", import.meta.url), "utf8"); new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
"utf8"
);
const styles = readFileSync(
new URL("../src/renderer/views/settings/settings.css", import.meta.url),
"utf8"
);
describe("account management layout", () => { describe("account management layout", () => {
it("keeps both account tabs inside the same fixed content row", () => { it("keeps both account tabs inside the same fixed content row", () => {
expect(appSource).toContain('<div className="account-settings-layout">'); expect(workspaceSource).toContain('<div className="settings-account-workspace">');
expect(appSource).not.toContain("account-settings-layout ${accountManagementTab}"); expect(workspaceSource.match(/className="settings-account-panel"/g)).toHaveLength(2);
expect(appSource).toContain('<div className="account-rules-panel" hidden={accountManagementTab !== "rules"}>'); expect(workspaceSource).toContain('hidden={model.activePanel !== "overview"}');
expect(styles).not.toMatch(/\.account-settings-layout\.rules\s*{/); expect(workspaceSource).toContain('hidden={model.activePanel !== "rules"}');
expect(styles).toMatch(/\.account-rules-panel\s*{[^}]*min-width:\s*0;[^}]*min-height:\s*0;[^}]*overflow-y:\s*auto;/s); expect(styles).toMatch(
/\.settings-account-workspace\s*{[^}]*grid-template-rows:\s*auto auto minmax\(0, 1fr\);[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s
);
expect(styles).toMatch(
/\.settings-account-panel\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s
);
expect(styles).toMatch(/\.settings-account-rules\s*{[^}]*min-width:\s*0;[^}]*overflow-y:\s*auto;/s);
}); });
}); });
+87
View File
@@ -0,0 +1,87 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AvatarMenu, getAvatarMenuKeyboardAction } from "../src/renderer/shell/AvatarMenu";
import { AppShell } from "../src/renderer/shell/AppShell";
import { buildMainNavigation } from "../src/renderer/shell/shell-model";
describe("desktop shell", () => {
it("exposes all five views with exactly one active item", () => {
const items = buildMainNavigation("downloads");
expect(items.map((item) => item.id)).toEqual(["downloads", "collector", "settings", "history", "statistics"]);
expect(items.filter((item) => item.active)).toHaveLength(1);
});
it("renders context regions without global placeholders", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="downloads"
onViewChange={() => {}}
sidebar={<div>Filter</div>}
sidebarStatus={<div>2 Downloads</div>}
headerActions={null}
toolbar={<div>Aktionen</div>}
footer={<div>11 von 1</div>}
contextInfo={null}
sidebarCollapsed={false}
onSidebarCollapsedChange={() => {}}
>
<div>Inhalt</div>
</AppShell>
);
expect(html).toContain("data-ui-region=\"header\"");
expect(html).toContain("data-ui-region=\"sidebar\"");
expect(html).toContain("data-ui-region=\"sidebar-status\"");
expect(html).toContain("data-ui-region=\"main\"");
expect(html).toContain("11 von 1");
});
it("does not reserve a collapsed sidebar column when sidebar slots are empty", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="settings"
onViewChange={() => {}}
sidebar={null}
sidebarStatus={null}
headerActions={null}
toolbar={null}
footer={null}
contextInfo={null}
sidebarCollapsed
onSidebarCollapsedChange={() => {}}
>
<div>Einstellungen</div>
</AppShell>
);
expect(html).not.toContain("has-collapsed-sidebar");
expect(html).not.toContain("data-ui-region=\"sidebar\"");
expect(html).not.toContain("data-ui-region=\"toolbar\"");
expect(html).not.toContain("data-ui-region=\"footer\"");
});
it("renders the account popover only while open", () => {
expect(renderToStaticMarkup(<AvatarMenu open={false} accountLabel="konto@example.test" actions={[]} onClose={() => {}} />)).toBe("");
const html = renderToStaticMarkup(
<AvatarMenu
open
accountLabel="konto@example.test"
actions={[{ id: "logout", label: "Abmelden", danger: true, onSelect: () => {} }]}
onClose={() => {}}
/>
);
expect(html).toContain("role=\"menu\"");
expect(html).toContain("aria-label=\"Kontomenü\"");
expect(html).toContain("konto@example.test");
expect(html).toContain("Abmelden");
expect(html).toContain("autofocus=\"\"");
});
it("maps menu keys to wrapped focus movement and closing", () => {
expect(getAvatarMenuKeyboardAction("ArrowDown", 0, 3)).toEqual({ type: "focus", index: 1 });
expect(getAvatarMenuKeyboardAction("ArrowDown", 2, 3)).toEqual({ type: "focus", index: 0 });
expect(getAvatarMenuKeyboardAction("ArrowUp", 0, 3)).toEqual({ type: "focus", index: 2 });
expect(getAvatarMenuKeyboardAction("Home", 2, 3)).toEqual({ type: "focus", index: 0 });
expect(getAvatarMenuKeyboardAction("End", 0, 3)).toEqual({ type: "focus", index: 2 });
expect(getAvatarMenuKeyboardAction("Escape", 1, 3)).toEqual({ type: "close" });
expect(getAvatarMenuKeyboardAction("Tab", 1, 3)).toBeNull();
});
});
+257
View File
@@ -0,0 +1,257 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
mergeCollectorDraftText,
planCollectorTabRemoval,
planCollectorTextReplacement
} from "../src/renderer/App";
import {
buildCollectorRows,
buildCollectorViewModel,
type CollectorSourceTab
} from "../src/renderer/views/collector/collector-model";
import {
CollectorInputDialog,
CollectorContent,
CollectorToolbar,
CollectorView,
type CollectorViewActions
} from "../src/renderer/views/collector/CollectorView";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) {
return;
}
visit(node);
visitElements(node.props.children, visit);
visitElements(node.props.actions, visit);
}
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && predicate(element)) {
result = element;
}
});
if (!result) {
throw new Error("Element not found");
}
return result;
}
function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function createActions(overrides: Partial<CollectorViewActions> = {}): CollectorViewActions {
return {
onTabSelect: () => {},
onTabAdd: () => {},
onTabRemove: () => {},
onOpenInput: () => {},
onImportDlc: () => {},
onImportFile: () => {},
onExportQueue: () => {},
onSubmit: () => {},
onQueryChange: () => {},
onSelectionChange: () => {},
onRemoveSelected: () => {},
...overrides
};
}
const populatedTabs: CollectorSourceTab[] = [
{
id: "tab-a",
name: "Sammlung A",
text: "https://example.test/a\n\n https://example.test/b "
}
];
describe("collector model", () => {
it("derives stable rows from non-empty raw lines without validating or regrouping them", () => {
const rows = buildCollectorRows(populatedTabs, "tab-a", "");
expect(rows).toHaveLength(2);
expect(rows.map((row) => row.id)).toEqual(["tab-a:0", "tab-a:2"]);
expect(rows.map((row) => row.originalLineIndex)).toEqual([0, 2]);
expect(rows.map((row) => row.value)).toEqual([
"https://example.test/a",
"https://example.test/b"
]);
expect(rows[0].linkCount).toBe(2);
expect(rows[1].linkCount).toBe(2);
});
it("filters presentation rows while keeping source counts and original line identities", () => {
const model = buildCollectorViewModel(populatedTabs, "tab-a", "EXAMPLE.TEST/B", false, ["tab-a:0"]);
expect(model.rows.map((row) => row.id)).toEqual(["tab-a:2"]);
expect(model.tabs).toEqual([{ id: "tab-a", name: "Sammlung A", linkCount: 2 }]);
expect(model.selectedIds).toEqual(["tab-a:0"]);
expect(model.empty).toBe(false);
});
it("preserves clipboard and drop appends that arrive while an input draft is open", () => {
expect(mergeCollectorDraftText(
"https://example.test/old",
"https://example.test/old\nhttps://example.test/clipboard",
"https://example.test/edited"
)).toBe("https://example.test/edited\nhttps://example.test/clipboard");
expect(mergeCollectorDraftText("old", "old", "edited")).toBe("edited");
});
it("moves the active identity to an existing neighbor before later appends arrive", () => {
const tabs: CollectorSourceTab[] = [
{ id: "tab-a", name: "Sammlung A", text: "a" },
{ id: "tab-b", name: "Sammlung B", text: "b" },
{ id: "tab-c", name: "Sammlung C", text: "c" }
];
expect(planCollectorTabRemoval(tabs, "tab-b", "tab-b")).toEqual({
tabs: [tabs[0], tabs[2]],
activeTabId: "tab-a"
});
expect(planCollectorTabRemoval(tabs, "tab-c", "tab-a")).toEqual({
tabs: [tabs[1], tabs[2]],
activeTabId: "tab-c"
});
});
it("invalidates positional row selection whenever raw text is replaced", () => {
const tabs: CollectorSourceTab[] = [
{ id: "tab-a", name: "Sammlung A", text: "old-a\nold-b" },
{ id: "tab-b", name: "Sammlung B", text: "untouched" }
];
expect(planCollectorTextReplacement(tabs, "tab-a", "new-a")).toEqual({
tabs: [
{ id: "tab-a", name: "Sammlung A", text: "new-a" },
tabs[1]
],
selectedIds: []
});
});
});
describe("CollectorView", () => {
it("keeps empty, busy and error states inside the same table body", () => {
const empty = renderToStaticMarkup(
<CollectorView
actions={createActions()}
model={buildCollectorViewModel([{ id: "tab-a", name: "Sammlung A", text: "" }], "tab-a", "", false, [])}
/>
);
const busy = renderToStaticMarkup(
<CollectorView
actions={createActions()}
model={{ ...buildCollectorViewModel([], "", "", true, []), error: "" }}
/>
);
const failed = renderToStaticMarkup(
<CollectorView
actions={createActions()}
model={{ ...buildCollectorViewModel([], "", "", false, []), error: "Import fehlgeschlagen" }}
/>
);
for (const [html, state] of [
[empty, "Noch keine Links"],
[busy, "Links werden verarbeitet"],
[failed, "Import fehlgeschlagen"]
]) {
expect(html.indexOf(state)).toBeGreaterThan(html.indexOf("data-visual-region=\"collector-table-body\""));
}
expect(empty).toContain("data-visual-region=\"collector-empty-state\"");
expect(empty).not.toContain("aria-label=\"Seitennavigation\"");
});
it("renders compact occupied rows and removes the empty marker", () => {
const html = renderToStaticMarkup(
<CollectorView
actions={createActions()}
model={buildCollectorViewModel(populatedTabs, "tab-a", "", false, [])}
/>
);
expect(html.match(/class=\"collector-row(?: is-selected)?\"/g)).toHaveLength(2);
expect(html).not.toContain("data-visual-region=\"collector-empty-state\"");
expect(html).toContain("data-visual-region=\"collector-sidebar\"");
expect(html).toContain("data-visual-region=\"collector-toolbar\"");
expect(html).toContain("data-visual-region=\"collector-table-body\"");
expect(html).not.toContain("data-visual-region=\"downloads-toolbar\"");
expect(html).not.toContain("aria-label=\"Seitennavigation\"");
});
it("separates local input, queue submission, search, selection and local removal callbacks", () => {
let inputOpens = 0;
let queueSubmits = 0;
let query = "";
let selected = "";
let removals = 0;
const actions = createActions({
onOpenInput: () => { inputOpens += 1; },
onSubmit: () => { queueSubmits += 1; },
onQueryChange: (value) => { query = value; },
onSelectionChange: (rowId) => { selected = rowId; },
onRemoveSelected: () => { removals += 1; }
});
const toolbar = CollectorToolbar({
actions,
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
});
const content = CollectorContent({
actions,
model: buildCollectorViewModel(populatedTabs, "tab-a", "", false, ["tab-a:0"])
});
findButton(toolbar, "Links hinzufügen").props.onClick();
expect(inputOpens).toBe(1);
expect(queueSubmits).toBe(0);
findButton(toolbar, "An Downloads übergeben").props.onClick();
expect(queueSubmits).toBe(1);
const search = findElement(toolbar, (element) => element.props.label === "Links durchsuchen");
search.props.onChange({ target: { value: "release" } });
expect(query).toBe("release");
const checkbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Link auswählen");
checkbox.props.onChange();
findButton(toolbar, "Auswahl entfernen").props.onClick();
expect(selected).toBe("tab-a:0");
expect(removals).toBe(1);
expect(queueSubmits).toBe(1);
});
it("names the input dialog and commits only through the local draft callback", () => {
let value = "";
let commits = 0;
const dialog = CollectorInputDialog({
open: true,
tabName: "Sammlung A",
value,
onChange: (next) => { value = next; },
onClose: () => {},
onCommit: () => { commits += 1; }
});
const html = renderToStaticMarkup(dialog);
expect(html).toContain("role=\"dialog\"");
expect(html).toContain("aria-label=\"Links\"");
expect(html).toContain("Links hinzufügen");
expect(html).toContain("Übernehmen");
const textbox = findElement(dialog, (element) => element.type === "textarea" && element.props["aria-label"] === "Links");
textbox.props.onChange({ target: { value: "https://example.test/new" } });
findButton(dialog, "Übernehmen").props.onClick();
expect(value).toBe("https://example.test/new");
expect(commits).toBe(1);
});
});
+102
View File
@@ -0,0 +1,102 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import {
clampContextMenuPosition,
ContextMenu,
getContextMenuKeyboardAction,
getContextMenuSubmenuKeyboardAction,
getContextSubmenuPosition
} from "../src/renderer/ui/ContextMenu";
describe("ContextMenu", () => {
it("renders menu semantics and marks buttons as menu items", () => {
const html = renderToStaticMarkup(
<ContextMenu ariaLabel="Aktionen" onClose={() => {}} open x={40} y={60}>
<button>Öffnen</button>
<button disabled>Gesperrt</button>
</ContextMenu>
);
expect(html).toContain("role=\"menu\"");
expect(html).toContain("aria-label=\"Aktionen\"");
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(2);
expect(html).toContain("tabindex=\"-1\"");
});
it("server-renders without layout-effect warnings", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
renderToStaticMarkup(
<ContextMenu onClose={() => {}} open x={0} y={0}>
<button>Öffnen</button>
</ContextMenu>
);
expect(error).not.toHaveBeenCalled();
error.mockRestore();
});
it("clamps every edge to the visible viewport", () => {
expect(clampContextMenuPosition(790, 590, 220, 180, 800, 600)).toEqual({ x: 580, y: 420 });
expect(clampContextMenuPosition(-12, -8, 220, 180, 800, 600)).toEqual({ x: 0, y: 0 });
expect(clampContextMenuPosition(40, 60, 220, 180, 800, 600)).toEqual({ x: 40, y: 60 });
});
it("navigates enabled items, activates Enter and closes only the menu on Escape", () => {
const enabled = [true, false, true, true];
expect(getContextMenuKeyboardAction("ArrowDown", 0, enabled)).toEqual({ type: "focus", index: 2 });
expect(getContextMenuKeyboardAction("ArrowDown", 3, enabled)).toEqual({ type: "focus", index: 0 });
expect(getContextMenuKeyboardAction("ArrowUp", 0, enabled)).toEqual({ type: "focus", index: 3 });
expect(getContextMenuKeyboardAction("Home", 3, enabled)).toEqual({ type: "focus", index: 0 });
expect(getContextMenuKeyboardAction("End", 0, enabled)).toEqual({ type: "focus", index: 3 });
expect(getContextMenuKeyboardAction("Enter", 2, enabled)).toEqual({ type: "activate", index: 2 });
expect(getContextMenuKeyboardAction("Escape", 2, enabled)).toEqual({ type: "close" });
expect(getContextMenuKeyboardAction("ArrowDown", -1, [false, false])).toBeNull();
});
it("opens and leaves submenus with standard keyboard commands", () => {
expect(getContextMenuSubmenuKeyboardAction("Enter", true, false)).toBe("open");
expect(getContextMenuSubmenuKeyboardAction("ArrowRight", true, false)).toBe("open");
expect(getContextMenuSubmenuKeyboardAction("ArrowLeft", false, true)).toBe("close");
expect(getContextMenuSubmenuKeyboardAction("Escape", false, true)).toBe("close");
expect(getContextMenuSubmenuKeyboardAction("ArrowDown", true, false)).toBeNull();
});
it("renders nested priority choices as an announced submenu", () => {
const html = renderToStaticMarkup(
<ContextMenu onClose={() => {}} open x={0} y={0}>
<div className="ctx-menu-sub">
<button aria-haspopup="menu">Priorität</button>
<div className="ctx-menu-sub-items" role="menu">
<button>Hoch</button>
<button>Standard</button>
<button>Niedrig</button>
</div>
</div>
</ContextMenu>
);
expect(html).toContain("aria-haspopup=\"menu\"");
expect(html.match(/role=\"menu\"/g)).toHaveLength(2);
expect(html.match(/role=\"menuitem\"/g)).toHaveLength(4);
});
it("places submenus inside the viewport on every edge", () => {
expect(getContextSubmenuPosition(
{ left: 700, right: 790, top: 40 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 520, y: 40 });
expect(getContextSubmenuPosition(
{ left: 8, right: 98, top: 40 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 98, y: 40 });
expect(getContextSubmenuPosition(
{ left: 500, right: 590, top: 560 },
{ width: 180, height: 150 },
{ width: 800, height: 600 }
)).toEqual({ x: 590, y: 450 });
});
});
+88
View File
@@ -0,0 +1,88 @@
import { createRef } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import {
Dialog,
getConnectedDialogRestoreTarget,
getDialogInitialFocusTarget,
getDialogKeyboardAction,
getDialogRestoreFocusTarget
} from "../src/renderer/ui/Dialog";
describe("Dialog", () => {
it("renders labelled modal semantics only while open", () => {
const closed = renderToStaticMarkup(
<Dialog actions={null} onClose={() => {}} open={false} title="Test">Body</Dialog>
);
const open = renderToStaticMarkup(
<Dialog actions={<button>OK</button>} description="Beschreibung" onClose={() => {}} open title="Test">Body</Dialog>
);
expect(closed).toBe("");
expect(open).toContain("role=\"dialog\"");
expect(open).toContain("aria-modal=\"true\"");
expect(open).toMatch(/aria-labelledby=\"[^\"]+\"/);
expect(open).toMatch(/aria-describedby=\"[^\"]+\"/);
expect(open).toContain("Beschreibung");
expect(open).toContain("Body");
expect(open).toContain("OK");
});
it("applies bounded account and update surfaces without changing the dialog contract", () => {
const account = renderToStaticMarkup(
<Dialog actions={null} initialFocusRef={createRef<HTMLButtonElement>()} onClose={() => {}} open size="account" title="Account">Body</Dialog>
);
const update = renderToStaticMarkup(
<Dialog actions={null} danger onClose={() => {}} open size="update" title="Update">Body</Dialog>
);
expect(account).toContain("md-dialog-size-account");
expect(update).toContain("md-dialog-size-update");
expect(update).toContain("is-danger");
});
it("traps forward and reverse tabbing and honors closable Escape", () => {
expect(getDialogKeyboardAction("Tab", false, -1, 4, true)).toEqual({ type: "focus", index: 0 });
expect(getDialogKeyboardAction("Tab", true, -1, 4, true)).toEqual({ type: "focus", index: 3 });
expect(getDialogKeyboardAction("Tab", false, 3, 4, true)).toEqual({ type: "focus", index: 0 });
expect(getDialogKeyboardAction("Tab", true, 0, 4, true)).toEqual({ type: "focus", index: 3 });
expect(getDialogKeyboardAction("Tab", false, 1, 4, true)).toBeNull();
expect(getDialogKeyboardAction("Escape", false, 0, 4, true)).toEqual({ type: "close" });
expect(getDialogKeyboardAction("Escape", false, 0, 4, false)).toBeNull();
});
it("keeps existing autofocus targets before falling back to the dialog surface", () => {
const explicitTarget = {} as HTMLElement;
const autofocusTarget = {} as HTMLElement;
const activeAutofocusTarget = {} as HTMLElement;
const dialog = {
contains: (target: HTMLElement) => target === activeAutofocusTarget,
querySelector: (selector: string) => selector === "[autofocus]" ? autofocusTarget : null
} as unknown as HTMLElement;
const fallbackDialog = { querySelector: () => null } as unknown as HTMLElement;
expect(getDialogInitialFocusTarget(dialog, explicitTarget)).toBe(explicitTarget);
expect(getDialogInitialFocusTarget(dialog, null, activeAutofocusTarget)).toBe(activeAutofocusTarget);
expect(getDialogInitialFocusTarget(dialog, null)).toBe(autofocusTarget);
expect(getDialogInitialFocusTarget(fallbackDialog, null)).toBe(fallbackDialog);
});
it("captures the opener before autofocus moves into the mounted dialog", () => {
const opener = { isConnected: true } as HTMLElement;
const dialogTarget = { isConnected: true } as HTMLElement;
const dialog = {
contains: (target: HTMLElement) => target === dialogTarget
} as unknown as HTMLElement;
expect(getDialogRestoreFocusTarget(dialog, opener)).toBe(opener);
expect(getDialogRestoreFocusTarget(dialog, dialogTarget)).toBeNull();
});
it("uses a stable caller fallback when a transient opener unmounts", () => {
const transientOpener = { isConnected: false } as HTMLElement;
const stableFallback = { isConnected: true } as HTMLElement;
expect(getConnectedDialogRestoreTarget(transientOpener, stableFallback)).toBe(stableFallback);
expect(getConnectedDialogRestoreTarget(stableFallback, null)).toBe(stableFallback);
});
});
+678
View File
@@ -0,0 +1,678 @@
import { readFileSync } from "node:fs";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { DownloadItem, DownloadStatus, PackageEntry } from "../src/shared/types";
import {
buildDownloadSidebarCounts,
buildDownloadsViewModel,
classifyDownloadStatus,
type DownloadSidebarFilter,
type DownloadsModelInput
} from "../src/renderer/views/downloads/downloads-model";
import {
DownloadsContent,
DownloadsFooter,
DownloadsSidebar,
DownloadsSidebarStatus,
DownloadsToolbar,
DownloadsView,
type DownloadsViewActions
} from "../src/renderer/views/downloads/DownloadsView";
import {
DownloadsTableHeader,
PackageCardContent,
areItemRowPropsEqual,
arePackageCardPropsEqual
} from "../src/renderer/views/downloads/DownloadsTable";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
function item(id: string, packageId: string, status: DownloadStatus, overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id,
packageId,
url: `https://rapidgator.net/file/${id}`,
provider: "realdebrid",
providerLabel: "Real-Debrid",
status,
retries: 0,
speedBps: status === "downloading" ? 12_000_000 : 0,
downloadedBytes: status === "completed" ? 2_000_000_000 : 500_000_000,
totalBytes: 2_000_000_000,
progressPercent: status === "completed" ? 100 : 25,
fileName: `${id}.mkv`,
targetPath: `C:\\Downloads\\${id}.mkv`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "Hoster nicht erreichbar" : "",
fullStatus: status,
createdAt: now - 1_000,
updatedAt: now,
...overrides
};
}
function pkg(id: string, name: string, itemIds: string[]): PackageEntry {
return { id, name, itemIds, createdAt: now } as PackageEntry;
}
function createInput(overrides: Partial<DownloadsModelInput> = {}): DownloadsModelInput {
const items = [
item("active", "package-a", "downloading"),
item("queued", "package-a", "queued", { provider: "debridlink", providerLabel: "Debrid-Link" }),
item("failed", "package-b", "failed", { provider: "alldebrid", providerLabel: "AllDebrid" }),
item("done", "package-b", "completed", { provider: "realdebrid", providerLabel: "Real-Debrid" })
];
const packages = [
pkg("package-a", "Aktive Serie", ["active", "queued"]),
pkg("package-b", "Archiv Paket", ["failed", "done"])
];
return {
packageOrder: packages.map((entry) => entry.id),
packages: Object.fromEntries(packages.map((entry) => [entry.id, entry])),
items: Object.fromEntries(items.map((entry) => [entry.id, entry])),
displayMode: "packages",
filter: "all",
providerFilter: "all",
query: "",
collapsedPackageIds: [],
selectedIds: [],
hideExtractedItems: false,
showAllPackages: false,
renderLimit: 260,
...overrides
};
}
function createActions(overrides: Partial<DownloadsViewActions> = {}): DownloadsViewActions {
return {
onDisplayModeChange: () => {},
onFilterChange: () => {},
onProviderFilterChange: () => {},
onQueryChange: () => {},
onAddLinks: () => {},
onStartDownloads: () => {},
onPauseDownloads: () => {},
onStopDownloads: () => {},
onToggleSchedule: () => {},
onScheduleTimeChange: () => {},
onActivateSchedule: () => {},
onCancelSchedule: () => {},
onMoveSelectionUp: () => {},
onMoveSelectionDown: () => {},
onRenameSelection: () => {},
onRemoveSelection: () => {},
onToggleClipboardWatcher: () => {},
onClearAll: () => {},
onToggleAllPackages: () => {},
onShowAllPackages: () => {},
onPackageDragStart: () => {},
onPackageDrop: () => {},
onPackageDragEnd: () => {},
onSetVisibleSelection: () => {},
onToggleSelection: () => {},
onSelectionMouseDown: () => {},
onSelectionMouseEnter: () => {},
onTogglePackage: () => {},
onTogglePackageCollapse: () => {},
onStartPackageRename: () => {},
onPackageRenameChange: () => {},
onCommitPackageRename: () => {},
onCancelPackageRename: () => {},
onCancelPackage: () => {},
onMovePackageUp: () => {},
onMovePackageDown: () => {},
onRemoveItem: () => {},
onOpenContextMenu: () => {},
onSortColumn: () => {},
onColumnDragStart: () => {},
onColumnDragOver: () => {},
onColumnDragLeave: () => {},
onColumnDrop: () => {},
onColumnDragEnd: () => {},
onColumnContextMenu: () => {},
...overrides
};
}
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) {
return;
}
visit(node);
visitElements(node.props.children, visit);
}
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && predicate(element)) {
result = element;
}
});
if (!result) {
throw new Error("Element not found");
}
return result;
}
function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function withRuntime(input: DownloadsModelInput, overrides: Record<string, unknown> = {}) {
return {
...buildDownloadsViewModel(input),
running: true,
paused: false,
canStart: true,
canPause: true,
canStop: true,
actionBusy: false,
reconnectSeconds: 0,
reconnectReason: "",
clipboardWatcher: true,
scheduleActive: false,
scheduleOpen: false,
scheduleTime: "23:30",
scheduleLabel: "",
packageSpeedBps: { "package-a": 12_000_000 },
editingPackageId: null,
editingName: "",
columnOrder: ["name", "size", "hoster", "progress"] as const,
gridTemplate: "minmax(280px, 2fr) 140px 160px minmax(220px, 1fr)",
status: {
packages: 2,
links: 4,
session: "3,00 GB",
total: "10,00 GB",
hosters: 3,
speed: "96,00 Mbit/s",
eta: "00:05:00"
},
...overrides
};
}
describe("downloads model", () => {
it("exports the sidebar filter contract used by the downloads shell", () => {
const filter: DownloadSidebarFilter = "queued";
expect(filter).toBe("queued");
});
it("builds sidebar counts without mutating the runtime items", () => {
const items = [
item("count-active", "count-package", "downloading"),
item("count-done", "count-package", "completed"),
item("count-cancelled", "count-package", "cancelled")
];
const snapshot = items.map((entry) => ({ ...entry }));
expect(buildDownloadSidebarCounts(items)).toEqual({ all: 3, active: 1, queued: 0, paused: 0, completed: 1, failed: 0 });
expect(items).toEqual(snapshot);
});
it("maps every runtime status into the exact semantic filter class", () => {
expect(classifyDownloadStatus("downloading")).toBe("active");
expect(classifyDownloadStatus("validating")).toBe("active");
expect(classifyDownloadStatus("extracting")).toBe("active");
expect(classifyDownloadStatus("integrity_check")).toBe("active");
expect(classifyDownloadStatus("queued")).toBe("queued");
expect(classifyDownloadStatus("reconnect_wait")).toBe("queued");
expect(classifyDownloadStatus("paused")).toBe("paused");
expect(classifyDownloadStatus("completed")).toBe("completed");
expect(classifyDownloadStatus("failed")).toBe("failed");
expect(classifyDownloadStatus("cancelled")).toBe("all");
});
it("derives sidebar counts from the complete queue before presentation filters", () => {
const model = buildDownloadsViewModel(createInput({ filter: "failed" }));
expect(model.counts).toEqual({ all: 4, active: 1, queued: 1, paused: 0, completed: 1, failed: 1 });
expect(model.visibleItemIds).toEqual(["failed"]);
});
it("filters by package name, file name, provider, status and extracted visibility", () => {
const byPackage = buildDownloadsViewModel(createInput({ query: "aktive serie" }));
const byFile = buildDownloadsViewModel(createInput({ query: "done.mkv" }));
const byProvider = buildDownloadsViewModel(createInput({ providerFilter: "debridlink" }));
const hiddenExtracted = buildDownloadsViewModel(createInput({
items: { ...createInput().items, done: item("done", "package-b", "completed", { fullStatus: "Entpackt" }) },
hideExtractedItems: true
}));
expect(byPackage.packageRows.map((row) => row.package.id)).toEqual(["package-a"]);
expect(byPackage.packageRows[0].items.map((entry) => entry.id)).toEqual(["active", "queued"]);
expect(byFile.packageRows.map((row) => row.package.id)).toEqual(["package-b"]);
expect(byFile.packageRows[0].items.map((entry) => entry.id)).toEqual(["done"]);
expect(byProvider.visibleItemIds).toEqual(["queued"]);
expect(hiddenExtracted.visibleItemIds).not.toContain("done");
});
it("supports the genuine flat file mode without synthetic package rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files" }));
expect(model.packageRows).toEqual([]);
expect(model.fileRows.map((entry) => entry.id)).toEqual(["active", "queued", "failed", "done"]);
expect(model.mainRowCount).toBe(4);
});
it("reports a filtered flat-file range from the actually rendered rows", () => {
const model = buildDownloadsViewModel(createInput({ displayMode: "files", filter: "failed" }));
expect(model.paginationLabel).toBe("1\u20131 von 1");
expect(model.totalMainRowCount).toBe(1);
});
it("counts cancelled downloads only in the complete all queue", () => {
const model = buildDownloadsViewModel(createInput({
packageOrder: ["cancelled-package"],
packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) },
items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") }
}));
expect(model.counts).toEqual({ all: 1, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 });
expect(buildDownloadsViewModel({ ...createInput(), filter: "completed", packageOrder: model.packageRows.map((row) => row.package.id), packages: { "cancelled-package": pkg("cancelled-package", "Abgebrochen", ["cancelled-item"]) }, items: { "cancelled-item": item("cancelled-item", "cancelled-package", "cancelled") } }).visibleItemIds).toEqual([]);
});
it("finds an account label without treating it as a provider id", () => {
const base = createInput();
const model = buildDownloadsViewModel({
...base,
items: {
...base.items,
active: { ...base.items.active, providerAccountLabel: "Privates Real-Debrid Konto" }
},
query: "privates real-debrid"
});
expect(model.visibleItemIds).toEqual(["active"]);
expect(model.providerFilter).toBe("all");
});
it("excludes collapsed children from visible and actionable row selection", () => {
const model = buildDownloadsViewModel(createInput({
collapsedPackageIds: ["package-a"],
selectedIds: ["package-a", "active", "queued"]
}));
expect(model.visibleRowIds).not.toContain("active");
expect(model.actionableSelectedIds).toEqual(["package-a"]);
});
it("limits occupied package rows honestly while preserving active packages and an actionable visible selection", () => {
const packageEntries = Array.from({ length: 264 }, (_, index) => pkg(`p-${index}`, `Paket ${index}`, [`i-${index}`]));
const itemEntries = packageEntries.map((entry, index) => item(`i-${index}`, entry.id, index === 263 ? "downloading" : "queued"));
const model = buildDownloadsViewModel(createInput({
packageOrder: packageEntries.map((entry) => entry.id),
packages: Object.fromEntries(packageEntries.map((entry) => [entry.id, entry])),
items: Object.fromEntries(itemEntries.map((entry) => [entry.id, entry])),
selectedIds: ["p-0", "i-0", "p-263", "i-263", "missing"],
renderLimit: 260
}));
expect(model.packageRows).toHaveLength(260);
expect(model.packageRows.some((row) => row.package.id === "p-263")).toBe(true);
expect(model.paginationLabel).toBe("1260 von 264");
expect(model.actionableSelectedIds).toEqual(["p-0", "i-0", "p-263", "i-263"]);
});
});
describe("downloads view", () => {
it("renders the five dense markers exactly once and the empty marker only for a true empty queue", () => {
const occupied = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const empty = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ packageOrder: [], packages: {}, items: {} }), { running: false })} />);
for (const marker of ["downloads-sidebar", "downloads-sidebar-status", "downloads-toolbar", "downloads-table-body", "downloads-pagination"]) {
expect(occupied.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
expect(empty.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
}
expect(occupied).not.toContain("data-visual-region=\"downloads-empty-state\"");
expect(empty).toContain("data-visual-region=\"downloads-empty-state\"");
expect(empty).toContain("F\u00fcge Links hinzu, um den ersten Download zu starten.");
});
it("renders the distinct filtered-empty guidance inside the table body", () => {
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput({ filter: "paused" }))} />);
expect(html).toContain("Keine passenden Downloads");
expect(html).toContain("Passe Filter oder Suche an.");
expect(html).not.toContain("data-visual-region=\"downloads-empty-state\"");
expect(html).toContain("0 von 0");
});
it("keeps sidebar, status, toolbar, table and footer as independently renderable production modules", () => {
const model = withRuntime(createInput());
const actions = createActions();
expect(renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />)).toContain("downloads-sidebar");
expect(renderToStaticMarkup(<DownloadsSidebarStatus model={model} />)).toContain("downloads-sidebar-status");
expect(renderToStaticMarkup(<DownloadsToolbar actions={actions} model={model} />)).toContain("downloads-toolbar");
expect(renderToStaticMarkup(<DownloadsContent actions={actions} model={model} />)).toContain("downloads-table-body");
expect(renderToStaticMarkup(<DownloadsFooter actions={actions} model={model} />)).toContain("downloads-pagination");
});
it("keeps the compact download search in the sidebar instead of the action toolbar", () => {
const model = withRuntime(createInput());
const actions = createActions();
const sidebar = renderToStaticMarkup(<DownloadsSidebar actions={actions} model={model} />);
const toolbar = renderToStaticMarkup(<DownloadsToolbar actions={actions} model={model} />);
expect(sidebar).toContain("downloads-sidebar-search");
expect(sidebar).toContain("Paket, Datei oder Service");
expect(toolbar).not.toContain("downloads-search-input");
});
it("forwards package drag lifecycle callbacks through the extracted downloads content", () => {
const calls: string[] = [];
const actions = createActions() as DownloadsViewActions & {
onPackageDragStart: (packageId: string) => void;
onPackageDrop: (packageId: string) => void;
onPackageDragEnd: () => void;
};
actions.onPackageDragStart = (packageId) => calls.push(`start:${packageId}`);
actions.onPackageDrop = (packageId) => calls.push(`drop:${packageId}`);
actions.onPackageDragEnd = () => calls.push("end");
const content = DownloadsContent({ actions, model: withRuntime(createInput()) });
const packageElement = findElement(content, (element) => element.props.row?.package.id === "package-a");
packageElement.props.onDragStart("package-a");
packageElement.props.onDrop("package-b");
packageElement.props.onDragEnd();
expect(calls).toEqual(["start:package-a", "drop:package-b", "end"]);
});
it("dispatches add, start, pause, stop, scheduling and selection actions through separate existing callbacks", () => {
const calls: string[] = [];
const actions = createActions({
onAddLinks: () => calls.push("add"),
onStartDownloads: () => calls.push("start"),
onPauseDownloads: () => calls.push("pause"),
onStopDownloads: () => calls.push("stop"),
onActivateSchedule: () => calls.push("schedule"),
onMoveSelectionUp: () => calls.push("up"),
onMoveSelectionDown: () => calls.push("down"),
onRenameSelection: () => calls.push("rename"),
onRemoveSelection: () => calls.push("remove")
});
const toolbar = DownloadsToolbar({
actions,
model: withRuntime(createInput({ selectedIds: ["active"] }), { scheduleOpen: true })
});
for (const label of ["Links hinzufügen", "Start", "Pause", "Stop", "Planen", "Nach oben", "Nach unten", "Umbenennen", "Entfernen"]) {
findButton(toolbar, label).props.onClick();
}
expect(calls).toEqual(["add", "start", "pause", "stop", "schedule", "up", "down", "rename", "remove"]);
});
it("uses exact toolbar disabled semantics without a dead reconnect branch", () => {
const toolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { canStart: false, canPause: false, canStop: false, reconnectSeconds: 8 })
});
expect(findButton(toolbar, "Start").props.disabled).toBe(true);
expect(findButton(toolbar, "Pause").props.disabled).toBe(true);
expect(findButton(toolbar, "Stop").props.disabled).toBe(true);
expect(renderToStaticMarkup(toolbar)).not.toContain("Reconnect");
});
it("keeps start available for resume and pause independent from unrelated action busy state", () => {
const pausedToolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { paused: true, canStart: false, canPause: true })
});
const busyToolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { paused: false, canPause: true, actionBusy: true })
});
expect(findButton(pausedToolbar, "Start").props.disabled).toBe(false);
expect(findButton(pausedToolbar, "Pause").props.disabled).toBe(true);
expect(findButton(busyToolbar, "Pause").props.disabled).toBe(false);
});
it("enables package movement only for a visible selected package row", () => {
const itemOnly = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput({ selectedIds: ["active"] }))
});
const packageSelected = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput({ selectedIds: ["package-a"] }))
});
expect(findButton(itemOnly, "Nach oben").props.disabled).toBe(true);
expect(findButton(itemOnly, "Nach unten").props.disabled).toBe(true);
expect(findButton(packageSelected, "Nach oben").props.disabled).toBe(false);
expect(findButton(packageSelected, "Nach unten").props.disabled).toBe(false);
});
it("keeps an active schedule visible and cancellable while the picker is closed", () => {
const toolbar = DownloadsToolbar({
actions: createActions(),
model: withRuntime(createInput(), { scheduleActive: true, scheduleOpen: false, scheduleLabel: "1m 30s" })
});
const html = renderToStaticMarkup(toolbar);
expect(html).toContain("Geplant: 1m 30s");
expect(findButton(toolbar, "Abbrechen").props.disabled).toBe(false);
});
it("keeps the table header and all rows in one horizontal scroll context with exact dense geometry", () => {
const html = renderToStaticMarkup(<DownloadsView actions={createActions()} model={withRuntime(createInput())} />);
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
expect(html.indexOf("downloads-table-header")).toBeGreaterThan(html.indexOf("downloads-table"));
expect(html.indexOf("data-visual-region=\"downloads-table-body\"")).toBeGreaterThan(html.indexOf("downloads-table-header"));
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;/s);
expect(css).toMatch(/\.downloads-table-header\s*\{[^}]*height:\s*41px;[^}]*position:\s*sticky;/s);
expect(css).toMatch(/\.downloads-item-row,\s*\.downloads-package-row\s*\{[^}]*height:\s*48px;/s);
expect(css).toMatch(/\.downloads-toolbar button,\s*\.downloads-footer button,[^{]+\{[^}]*height:\s*36px;/s);
expect(css).toMatch(/\.downloads-content\s*\{[^}]*height:\s*100%;/s);
expect(css).toMatch(/\.downloads-action-cell button,\s*\.downloads-collapse-button\s*\{[^}]*width:\s*30px;[^}]*height:\s*30px;/s);
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid var\(--ui-border\);[^}]*padding:\s*0;/s);
expect(css).not.toMatch(/gradient|box-shadow|nth-child/i);
});
it("keeps visible interaction text non-selectable and only inputs plus copy values selectable", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
expect(css).toMatch(/\.downloads-sidebar,\s*\.downloads-sidebar-status,\s*\.downloads-toolbar,\s*\.downloads-content,\s*\.downloads-footer\s*\{[^}]*user-select:\s*none;/s);
expect(css).toMatch(/\.downloads-copyable,\s*\.downloads-search-input,\s*\.downloads-rename-input\s*\{[^}]*user-select:\s*text;/s);
});
it("keeps the 1120px layout inside the single downloads table scroll owner", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
expect(css).toMatch(/@media \(max-width:\s*1120px\)/);
expect(css).toMatch(/\.downloads-content\s*\{[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.downloads-table\s*\{[^}]*overflow-x:\s*auto;[^}]*overflow-y:\s*auto;/s);
});
it("uses only semantic color variables declared by the shared theme", () => {
const css = readFileSync(new URL("../src/renderer/views/downloads/downloads.css", import.meta.url), "utf8");
const theme = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
const usedVariables = [...css.matchAll(/var\((--ui-[a-z-]+)/g)].map((match) => match[1]);
const declaredVariables = new Set([...theme.matchAll(/(--ui-[a-z-]+)\s*:/g)].map((match) => match[1]));
expect([...new Set(usedVariables)].filter((name) => !declaredVariables.has(name))).toEqual([]);
});
});
describe("downloads App integration", () => {
it("uses the extracted table only, cleans temporary drag listeners and routes the global context start through the shared callback", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
expect(source).not.toMatch(/^const ItemRow\b|^const PackageCard\b|^interface ItemRowProps\b|^interface PackageCardProps\b/m);
expect(source).toContain('window.removeEventListener("mouseup", dragMouseUpRef.current)');
expect(source).toContain("downloadsActions.onStartDownloads(); setContextMenu(null);");
expect(source).not.toContain(") : false ? (");
expect(source).not.toContain("{false && (");
});
});
describe("download table row contracts", () => {
it("preserves the package download and extraction phase split", () => {
const extractionPackage = {
...pkg("extracting-package", "Entpackendes Paket", ["extracting-item"]),
status: "extracting"
} as PackageEntry;
const extractionItem = item("extracting-item", extractionPackage.id, "completed", {
fullStatus: "Entpacken 40%",
progressPercent: 100
});
const html = renderToStaticMarkup(PackageCardContent({
actions: createActions(),
columnOrder: ["progress"],
editing: false,
editingName: "",
gridTemplate: "80px",
packageSpeedBps: 0,
row: { package: extractionPackage, items: [extractionItem], collapsed: true },
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(html).toContain("<b>70%</b>");
});
it("sets the whole visible selection atomically from the header checkbox", () => {
const calls: unknown[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSetVisibleSelection: (ids, selected) => calls.push([ids, selected]) }),
columnOrder: ["name"],
gridTemplate: "minmax(280px, 1fr)",
selectedCount: 1,
sortColumn: "name",
sortDirection: "asc",
visibleIds: ["package-a", "active", "queued"]
});
const checkbox = findElement(header, (element) => element.type === "input");
checkbox.props.onChange({ target: { checked: true } });
expect(calls).toEqual([[['package-a', 'active', 'queued'], true]]);
});
it("includes package selection state in memo equality", () => {
const model = withRuntime(createInput());
const row = model.packageRows[0];
const base = {
actions: createActions(),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set<string>(),
selectedVersion: 1
};
expect(arePackageCardPropsEqual(base, { ...base, selectedIds: new Set([row.package.id]), selectedVersion: 2 })).toBe(false);
});
it("invalidates visible item rows when provider, error or timestamp presentation changes", () => {
const model = withRuntime(createInput());
const base = {
actions: createActions(),
columnOrder: model.columnOrder,
gridTemplate: model.gridTemplate,
item: model.packageRows[0].items[0],
selected: false
};
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, providerLabel: "Debrid-Link" } })).toBe(false);
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, lastError: "Neuer Fehler" } })).toBe(false);
expect(areItemRowPropsEqual(base, { ...base, item: { ...base.item, updatedAt: base.item.updatedAt + 1 } })).toBe(false);
});
it("does not collapse a package on shift selection", () => {
const calls: string[] = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onToggleSelection: () => calls.push("select"),
onTogglePackageCollapse: () => calls.push("collapse")
}),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set<string>(),
selectedVersion: 1
});
const packageRow = findElement(component, (element) => element.props["data-download-row-id"] === row.package.id);
packageRow.props.onClick({ button: 0, ctrlKey: false, metaKey: false, shiftKey: true, target: {}, currentTarget: { contains: () => true } });
expect(calls).toEqual(["select"]);
});
it("commits Enter and the resulting Blur rename sequence exactly once", () => {
const commits: string[] = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({ onCommitPackageRename: (id, value) => commits.push(`${id}:${value}`) }),
columnOrder: model.columnOrder,
editing: true,
editingName: "Neuer Name",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set<string>(),
selectedVersion: 1
});
const input = findElement(component, (element) => element.type === "input" && element.props.className === "downloads-rename-input");
input.props.onKeyDown({ key: "Enter", preventDefault: () => {}, currentTarget: { blur: () => {} } });
input.props.onBlur();
expect(commits).toEqual(["package-a:Neuer Name"]);
});
it("keeps package selection and activation as separate controls and sends context coordinates", () => {
const calls: Array<unknown> = [];
const model = withRuntime(createInput());
const row = model.packageRows[0];
const component = PackageCardContent({
actions: createActions({
onToggleSelection: (id) => calls.push(["select", id]),
onTogglePackage: (id) => calls.push(["toggle", id]),
onOpenContextMenu: (id, x, y) => calls.push(["context", id, x, y])
}),
columnOrder: model.columnOrder,
editing: false,
editingName: "",
gridTemplate: model.gridTemplate,
packageSpeedBps: 0,
row,
selectedIds: new Set<string>(),
selectedVersion: 1
});
const selection = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie auswählen");
const activation = findElement(component, (element) => element.type === "input" && element.props["aria-label"] === "Aktive Serie aktivieren");
const packageElement = findElement(component, (element) => element.props["data-download-package-id"] === "package-a");
selection.props.onChange();
activation.props.onChange();
packageElement.props.onContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: 30, clientY: 50 });
expect(calls).toEqual([["select", "package-a"], ["toggle", "package-a"], ["context", "package-a", 30, 50]]);
});
});
+142
View File
@@ -0,0 +1,142 @@
import { win32 } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { HistoryEntry } from "../src/shared/types";
import {
revealHistoryEntry,
type HistoryRevealDependencies
} from "../src/main/history-reveal";
function historyEntry(overrides: Partial<HistoryEntry> = {}): HistoryEntry {
return {
id: "known-id",
name: "Paket",
totalBytes: 1,
downloadedBytes: 1,
fileCount: 1,
provider: "realdebrid",
completedAt: 1,
durationSeconds: 1,
status: "completed",
outputDir: "C:\\Downloads\\Paket",
urls: [],
...overrides
};
}
function dependencies(overrides: Partial<HistoryRevealDependencies> = {}): HistoryRevealDependencies {
return {
loadHistory: () => [historyEntry()],
stat: vi.fn(async () => ({ isDirectory: () => true })),
openPath: vi.fn(async () => ""),
...overrides
};
}
describe("revealHistoryEntry", () => {
it.each(["", " ", " padded", "padded ", "x".repeat(257)])("rejects malformed entry id %j before loading history", async (entryId) => {
const loadHistory = vi.fn(() => [historyEntry()]);
const deps = dependencies({ loadHistory });
await expect(revealHistoryEntry({ entryId }, deps)).resolves.toEqual({ ok: false, reason: "entry-not-found" });
expect(loadHistory).not.toHaveBeenCalled();
expect(deps.stat).not.toHaveBeenCalled();
expect(deps.openPath).not.toHaveBeenCalled();
});
it("resolves a known case-sensitive id to the authoritative directory and opens it exactly once", async () => {
const deps = dependencies();
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
expect(deps.stat).toHaveBeenCalledTimes(1);
expect(deps.stat).toHaveBeenCalledWith("C:\\Downloads\\Paket");
expect(deps.openPath).toHaveBeenCalledTimes(1);
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
});
it("ignores every renderer-supplied field except entryId", async () => {
const deps = dependencies();
await expect(revealHistoryEntry({ entryId: "known-id", outputDir: "C:\\Angriff" } as never, deps)).resolves.toEqual({ ok: true });
expect(deps.openPath).toHaveBeenCalledWith("C:\\Downloads\\Paket");
});
it("rejects unknown and differently cased ids before path inspection", async () => {
const deps = dependencies();
await expect(revealHistoryEntry({ entryId: "KNOWN-ID" }, deps)).resolves.toEqual({ ok: false, reason: "entry-not-found" });
expect(deps.stat).not.toHaveBeenCalled();
expect(deps.openPath).not.toHaveBeenCalled();
});
it.each([
"relative\\folder",
"C:relative\\folder",
"\\current-drive-rooted",
"/current-drive-rooted",
"\\\\server",
"\\\\server\\",
"\\\\..\\share\\folder",
"\\\\server\\.\\folder",
"\\\\server\\..\\folder",
"\\\\.\\C:\\folder",
"\\\\?\\C:\\folder",
"\\\\?\\GLOBALROOT\\Device\\HarddiskVolumeShadowCopy1",
"C:\\folder:stream",
"\\\\server\\share\\folder:stream",
"C:\\folder\0bad",
"C:\\folder\nbad",
"C:\\bad?name"
])("rejects unsafe or non-absolute Windows path %s", async (outputDir) => {
const deps = dependencies({ loadHistory: () => [historyEntry({ outputDir })] });
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: false, reason: "invalid-output-dir" });
expect(deps.stat).not.toHaveBeenCalled();
expect(deps.openPath).not.toHaveBeenCalled();
});
it.each([
["C:/Media/Folder//Child", win32.normalize("C:/Media/Folder//Child")],
["\\\\server\\share\\Folder\\Child\\", win32.normalize("\\\\server\\share\\Folder\\Child\\")]
])("normalizes valid drive and UNC paths before stat and openPath", async (outputDir, normalized) => {
const deps = dependencies({ loadHistory: () => [historyEntry({ outputDir })] });
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
expect(deps.stat).toHaveBeenCalledWith(normalized);
expect(deps.openPath).toHaveBeenCalledWith(normalized);
});
it("maps a missing directory and a file target without calling openPath", async () => {
const missing = dependencies({ stat: vi.fn(async () => { throw Object.assign(new Error("missing"), { code: "ENOENT" }); }) });
const file = dependencies({ stat: vi.fn(async () => ({ isDirectory: () => false })) });
await expect(revealHistoryEntry({ entryId: "known-id" }, missing)).resolves.toEqual({ ok: false, reason: "output-dir-missing" });
await expect(revealHistoryEntry({ entryId: "known-id" }, file)).resolves.toEqual({ ok: false, reason: "output-dir-not-directory" });
expect(missing.openPath).not.toHaveBeenCalled();
expect(file.openPath).not.toHaveBeenCalled();
});
it.each([
Object.assign(new Error("denied"), { code: "EACCES" }),
new Error("network unavailable")
])("maps non-missing stat failures to open-failed without calling openPath", async (error) => {
const deps = dependencies({ stat: vi.fn(async () => { throw error; }) });
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: false, reason: "open-failed" });
expect(deps.openPath).not.toHaveBeenCalled();
});
it("accepts followed junction or symlink stats when the resolved target is a directory", async () => {
const deps = dependencies({ stat: vi.fn(async () => ({ isDirectory: () => true, isSymbolicLink: () => true })) });
await expect(revealHistoryEntry({ entryId: "known-id" }, deps)).resolves.toEqual({ ok: true });
expect(deps.openPath).toHaveBeenCalledTimes(1);
});
it("treats a non-empty shell result and a rejected shell promise as open failures", async () => {
const returnedError = dependencies({ openPath: vi.fn(async () => "Zugriff verweigert") });
const rejected = dependencies({ openPath: vi.fn(async () => { throw new Error("shell failed"); }) });
await expect(revealHistoryEntry({ entryId: "known-id" }, returnedError)).resolves.toEqual({ ok: false, reason: "open-failed" });
await expect(revealHistoryEntry({ entryId: "known-id" }, rejected)).resolves.toEqual({ ok: false, reason: "open-failed" });
});
});
+429
View File
@@ -0,0 +1,429 @@
import { readFileSync } from "node:fs";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { HistoryEntry } from "../src/shared/types";
import {
buildHistoryViewModel,
deriveHistoryHoster,
deriveHistoryStartAt,
filterHistoryRows,
pruneHistoryIds,
selectVisibleHistoryIds,
type HistoryFilter,
type HistoryViewEntry
} from "../src/renderer/views/history/history-model";
import {
HistoryContent,
HistoryToolbar,
HistoryView,
type HistoryViewActions
} from "../src/renderer/views/history/HistoryView";
import { createVisualFixture } from "./visual/fixtures";
import { createVisualElectronApi } from "./visual/mock-electron-api";
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) {
return;
}
visit(node);
visitElements(node.props.children, visit);
}
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && predicate(element)) {
result = element;
}
});
if (!result) {
throw new Error("Element not found");
}
return result;
}
function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label);
}
function createActions(overrides: Partial<HistoryViewActions> = {}): HistoryViewActions {
return {
onFilterChange: () => {},
onQueryChange: () => {},
onToggleSelection: () => {},
onToggleSelectAll: () => {},
onToggleExpansion: () => {},
onRestore: () => {},
onReveal: () => {},
onRemove: () => {},
onClearSelection: () => {},
onClearHistory: () => {},
onContextMenu: () => {},
...overrides
};
}
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
const todayStart = new Date(2026, 7, 10, 0, 0, 0, 0).getTime();
const weekStart = new Date(2026, 7, 4, 0, 0, 0, 0).getTime();
function entry(overrides: Partial<HistoryViewEntry> & Pick<HistoryViewEntry, "id" | "name">): HistoryViewEntry {
return {
totalBytes: 2_000_000_000,
downloadedBytes: 1_500_000_000,
fileCount: 2,
provider: "realdebrid",
completedAt: todayStart + 60_000,
durationSeconds: 60,
status: "completed",
outputDir: `C:\\Downloads\\${overrides.name}`,
urls: ["https://rapidgator.net/file/test"],
...overrides,
id: overrides.id,
name: overrides.name
};
}
const entries: HistoryViewEntry[] = [
entry({ id: "today", name: "Heute Paket", completedAt: todayStart + 1 }),
entry({ id: "week-edge", name: "Wochenanfang", completedAt: weekStart }),
entry({ id: "week", name: "Wochen Paket", completedAt: todayStart - 1, status: "deleted", provider: "debridlink", urls: ["https://ddownload.com/a"] }),
entry({ id: "older", name: "Altes Paket", completedAt: weekStart - 1, status: "failed", provider: null, outputDir: "D:\\Archiv\\Alt", urls: ["https://sub.example.test/a"] })
];
describe("history model", () => {
it("separates today, previous six calendar days, older and status filters at exact boundaries", () => {
const expected: Record<HistoryFilter, string[]> = {
all: ["today", "week-edge", "week", "older"],
today: ["today"],
week: ["week-edge", "week"],
older: ["older"],
completed: ["today", "week-edge"],
deleted: ["week"],
failed: ["older"]
};
for (const [filter, ids] of Object.entries(expected) as Array<[HistoryFilter, string[]]>) {
expect(filterHistoryRows(entries, filter, "", now).map((row) => row.id)).toEqual(ids);
}
});
it("uses local calendar midnights for the six previous days across both daylight-saving transitions", () => {
const springNow = new Date(2026, 2, 30, 12, 0, 0, 0).getTime();
const springBoundary = new Date(2026, 2, 24, 0, 0, 0, 0).getTime();
const autumnNow = new Date(2026, 9, 26, 12, 0, 0, 0).getTime();
const autumnBoundary = new Date(2026, 9, 20, 0, 0, 0, 0).getTime();
expect(filterHistoryRows([
entry({ id: "spring-before", name: "Spring before", completedAt: springBoundary - 30 * 60 * 1000 }),
entry({ id: "spring-boundary", name: "Spring boundary", completedAt: springBoundary })
], "week", "", springNow).map((row) => row.id)).toEqual(["spring-boundary"]);
expect(filterHistoryRows([
entry({ id: "autumn-boundary", name: "Autumn boundary", completedAt: autumnBoundary + 30 * 60 * 1000 })
], "week", "", autumnNow).map((row) => row.id)).toEqual(["autumn-boundary"]);
});
it("bounds today to the exact local calendar day and excludes future timestamps", () => {
const tomorrowStart = new Date(2026, 7, 11, 0, 0, 0, 0).getTime();
const temporalEntries = [
entry({ id: "today-last", name: "Today last", completedAt: tomorrowStart - 1 }),
entry({ id: "tomorrow", name: "Tomorrow", completedAt: tomorrowStart }),
entry({ id: "future", name: "Future", completedAt: tomorrowStart + 86_400_000 })
];
expect(filterHistoryRows(temporalEntries, "today", "", now).map((row) => row.id)).toEqual(["today-last"]);
});
it("searches name, path, hoster, provider and URLs without changing newest-first input order", () => {
const searchable = [
entry({ id: "new", name: "Neu", completedAt: now, provider: "debridlink", outputDir: "C:\\Filme\\Staffel", urls: ["https://rapidgator.net/file/needle"] }),
entry({ id: "old", name: "Älter", completedAt: now - 1, provider: "realdebrid", outputDir: "D:\\Archiv", urls: ["https://ddownload.com/archive"] })
];
expect(filterHistoryRows(searchable, "all", "neu", now).map((row) => row.id)).toEqual(["new"]);
expect(filterHistoryRows(searchable, "all", "staffel", now).map((row) => row.id)).toEqual(["new"]);
expect(filterHistoryRows(searchable, "all", "rapidgator.net", now).map((row) => row.id)).toEqual(["new"]);
expect(filterHistoryRows(searchable, "all", "Debrid-Link", now).map((row) => row.id)).toEqual(["new"]);
expect(filterHistoryRows(searchable, "all", "needle", now).map((row) => row.id)).toEqual(["new"]);
expect(filterHistoryRows(searchable, "all", "d", now).map((row) => row.id)).toEqual(["new", "old"]);
});
it("derives hosters only from valid URL hostnames and clamps the calculated start time", () => {
expect(deriveHistoryHoster(["https://rapidgator.net/a", "https://rapidgator.net/b", "https://ddownload.com/c", "not a url"])).toBe("rapidgator.net, ddownload.com");
expect(deriveHistoryHoster([])).toBe("—");
expect(deriveHistoryHoster(undefined)).toBe("—");
expect(deriveHistoryStartAt(entry({ id: "start", name: "Start", completedAt: 20_000, durationSeconds: 3 }))).toBe(17_000);
expect(deriveHistoryStartAt(entry({ id: "clamped", name: "Clamp", completedAt: 2_000, durationSeconds: 3 }))).toBe(0);
const row = filterHistoryRows([entry({ id: "provider", name: "Provider", provider: "realdebrid", urls: [] })], "all", "", now)[0];
expect(row.hoster).toBe("—");
expect(row.providerLabel).toBe("Real-Debrid");
});
it("prunes removed ids and preserves the original set instance when every id survives", () => {
const stable = new Set(["today", "week"]);
expect(pruneHistoryIds(stable, ["today", "week", "older"])).toBe(stable);
const pruned = pruneHistoryIds(new Set(["today", "removed"]), ["today", "week"]);
expect([...pruned]).toEqual(["today"]);
});
it("builds Ctrl+A selection from only the currently visible filtered row ids", () => {
const visibleIds = filterHistoryRows(entries, "week", "Wochen", now).map((row) => row.id);
expect([...selectVisibleHistoryIds(visibleIds)]).toEqual(["week-edge", "week"]);
});
it("removes hidden selected ids from the filtered view model and every toolbar action", () => {
const model = buildHistoryViewModel(entries, "deleted", "", ["today", "week"], [], false, "", now);
const calls: Array<unknown> = [];
const toolbar = HistoryToolbar({
model,
actions: createActions({
onRestore: (ids) => calls.push(["restore", ids]),
onReveal: (id) => calls.push(["reveal", id]),
onRemove: (ids) => calls.push(["remove", ids])
})
});
expect(model.rows.map((row) => row.id)).toEqual(["week"]);
expect(model.selectedIds).toEqual(["week"]);
findButton(toolbar, "Erneut hinzufügen").props.onClick();
findButton(toolbar, "Im Ordner zeigen").props.onClick();
findButton(toolbar, "Entfernen").props.onClick();
expect(calls).toEqual([
["restore", ["week"]],
["reveal", "week"],
["remove", ["week"]]
]);
});
});
describe("HistoryView", () => {
it("keeps the header and rows inside the same internal horizontal scroll context", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
/>
);
const css = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
const tableStart = html.indexOf("history-table");
const headerStart = html.indexOf("history-table-header");
const bodyStart = html.indexOf("data-visual-region=\"history-table-body\"");
expect(tableStart).toBeGreaterThan(-1);
expect(headerStart).toBeGreaterThan(tableStart);
expect(bodyStart).toBeGreaterThan(headerStart);
expect(css).toMatch(/\.history-content \.history-table\s*\{[^}]*overflow:\s*auto;/s);
expect(css).toMatch(/\.history-table > \.history-table-header\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;/s);
expect(css).toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*visible;/s);
expect(css).not.toMatch(/\.history-table > \.history-table-body\s*\{[^}]*overflow:\s*auto;/s);
});
it("keeps every real AppShell history surface non-selectable while allowing text selection only for detail values", () => {
const css = readFileSync(new URL("../src/renderer/views/history/history.css", import.meta.url), "utf8");
expect(css).toMatch(/\.history-sidebar,\s*\.history-workspace-toolbar,\s*\.history-content,\s*\.history-pagination\s*\{[^}]*user-select:\s*none;/s);
expect(css).toMatch(/\.history-copyable\s*\{[^}]*user-select:\s*text;/s);
expect(css).toMatch(/\.history-workspace-toolbar \.ui-toolbar-search-input\s*\{[^}]*user-select:\s*text;/s);
expect(css).not.toMatch(/(^|\n)\.history-toolbar(?:\s|,|\{)/);
expect(css).not.toMatch(/(^|\n)\.history-detail-grid(?:\s|>|\.|\{)/);
});
it("keeps loading, empty, filtered-empty and error states inside the same table body", () => {
const states = [
[buildHistoryViewModel([], "all", "", [], [], true, "", now), "Verlauf wird geladen"],
[buildHistoryViewModel([], "all", "", [], [], false, "", now), "Noch kein Verlauf"],
[buildHistoryViewModel([entry({ id: "done", name: "Fertig" })], "failed", "", [], [], false, "", now), "Keine passenden Einträge"],
[buildHistoryViewModel([], "all", "", [], [], false, "Verlauf konnte nicht geladen werden", now), "Verlauf konnte nicht geladen werden"]
] as const;
for (const [model, label] of states) {
const html = renderToStaticMarkup(<HistoryView actions={createActions()} model={model} />);
expect(html.indexOf(label)).toBeGreaterThan(html.indexOf("data-visual-region=\"history-table-body\""));
expect(html).toContain("data-visual-region=\"history-pagination\"");
expect(html).toContain("0 von 0");
}
});
it("renders the exact compact headers, semantic statuses and no operative download controls", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel(entries, "all", "", [], [], false, "", now)}
/>
);
const headerStart = html.indexOf("history-table-header-row");
const headerEnd = html.indexOf("data-visual-region=\"history-table-body\"");
const headerMarkup = html.slice(headerStart, headerEnd);
const headers = ["Paket / Datei", "Status", "Größe", "Hoster", "Gestartet", "Beendet", "Aktion"];
let previous = -1;
for (const header of headers) {
const index = headerMarkup.indexOf(`>${header}<`);
expect(index).toBeGreaterThan(previous);
previous = index;
}
expect(html).toContain("history-status-completed");
expect(html).toContain("history-status-deleted");
expect(html).toContain("history-status-failed");
expect(html).toContain("Abgeschlossen");
expect(html).toContain("Gelöscht");
expect(html).toContain("Fehlgeschlagen");
expect(html).not.toMatch(/>Start<|>Pause<|>Stop<|Priorität/);
});
it("renders each visual marker once, occupied main rows separately from closed detail rows and an honest footer", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now)}
/>
);
for (const marker of ["history-sidebar", "history-toolbar", "history-table-body", "history-pagination"]) {
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
}
expect(html.match(/data-history-row-id=/g)).toHaveLength(2);
expect(html).not.toContain("history-detail-row");
expect(html).toContain("12 von 2");
});
it("dispatches selection, expansion, select-all and context coordinates with exact visible ids", () => {
const calls: Array<unknown> = [];
const actions = createActions({
onToggleSelection: (id) => calls.push(["select", id]),
onToggleSelectAll: (ids) => calls.push(["all", ids]),
onToggleExpansion: (id) => calls.push(["expand", id]),
onContextMenu: (id, x, y) => calls.push(["context", id, x, y])
});
const model = buildHistoryViewModel(entries.slice(0, 2), "all", "", [], [], false, "", now);
const content = HistoryContent({ actions, model });
const selectAll = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Alle sichtbaren Einträge auswählen");
selectAll.props.onChange();
const rowCheckbox = findElement(content, (element) => element.type === "input" && element.props["aria-label"] === "Heute Paket auswählen");
rowCheckbox.props.onChange();
findElement(content, (element) => element.type === "button" && element.props["aria-label"] === "Details anzeigen").props.onClick({ stopPropagation: () => {} });
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
row.props.onContextMenu({
preventDefault: () => {},
stopPropagation: () => {},
clientX: 144,
clientY: 288,
currentTarget: { querySelector: () => null }
});
expect(calls).toEqual([
["all", ["today", "week-edge"]],
["select", "today"],
["expand", "today"],
["context", "today", 144, 288]
]);
});
it("focuses the matching row action before opening a genuine row context menu", () => {
const calls: Array<unknown> = [];
const focusCalls: Array<unknown> = [];
const content = HistoryContent({
actions: createActions({ onContextMenu: (id, x, y) => calls.push([id, x, y]) }),
model: buildHistoryViewModel(entries.slice(0, 1), "all", "", [], [], false, "", now)
});
const row = findElement(content, (element) => element.props["data-history-row-id"] === "today");
row.props.onContextMenu({
preventDefault: () => {},
stopPropagation: () => {},
clientX: 21,
clientY: 34,
currentTarget: {
querySelector: () => ({ focus: (options: unknown) => focusCalls.push(options) })
}
});
expect(focusCalls).toEqual([{ preventScroll: true }]);
expect(calls).toEqual([["today", 21, 34]]);
});
it("enables reveal only for exactly one selected row and sends selection actions as ids", () => {
const selected = buildHistoryViewModel(entries, "all", "", ["today"], [], false, "", now);
const multiple = buildHistoryViewModel(entries, "all", "", ["today", "week"], [], false, "", now);
const calls: Array<unknown> = [];
const actions = createActions({
onRestore: (ids) => calls.push(["restore", ids]),
onReveal: (id) => calls.push(["reveal", id]),
onRemove: (ids) => calls.push(["remove", ids]),
onClearSelection: () => calls.push(["clear"])
});
const singleToolbar = HistoryToolbar({ actions, model: selected });
const multiToolbar = HistoryToolbar({ actions, model: multiple });
expect(findButton(singleToolbar, "Im Ordner zeigen").props.disabled).toBe(false);
expect(findButton(multiToolbar, "Im Ordner zeigen").props.disabled).toBe(true);
findButton(singleToolbar, "Erneut hinzufügen").props.onClick();
findButton(singleToolbar, "Im Ordner zeigen").props.onClick();
findButton(singleToolbar, "Entfernen").props.onClick();
findButton(singleToolbar, "Auswahl löschen").props.onClick();
expect(calls).toEqual([
["restore", ["today"]],
["reveal", "today"],
["remove", ["today"]],
["clear"]
]);
});
it("renders expanded paths and URLs as copyable details without changing the 48px main-row contract", () => {
const html = renderToStaticMarkup(
<HistoryView
actions={createActions()}
model={buildHistoryViewModel(entries.slice(0, 1), "all", "", [], ["today"], false, "", now)}
/>
);
expect(html).toContain("history-detail-row");
expect(html).toContain("history-copyable");
expect(html).toContain("C:\\Downloads\\Heute Paket");
expect(html).toContain("https://rapidgator.net/file/test");
});
});
describe("visual history states", () => {
it("re-arms the real App mounted gate before every StrictMode lifecycle setup can start async work", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8").replaceAll("\r\n", "\n");
const firstRequest = source.indexOf("window.rd.getVersion()");
const effectStart = source.lastIndexOf("useEffect(() => {", firstRequest);
const cleanup = source.indexOf("mountedRef.current = false", firstRequest);
const setup = source.indexOf("mountedRef.current = true", effectStart);
expect(effectStart).toBeGreaterThan(-1);
expect(setup).toBeGreaterThan(effectStart);
expect(setup).toBeLessThan(firstRequest);
expect(cleanup).toBeGreaterThan(firstRequest);
});
it("keeps bootstrap deterministic before exposing loading and error responses to the opened history view", async () => {
const loadingApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=loading");
await expect(loadingApi.getHistory()).resolves.toHaveLength(2);
const pending = loadingApi.getHistory();
let settled = false;
void pending.finally(() => { settled = true; });
await Promise.resolve();
await Promise.resolve();
expect(settled).toBe(false);
const errorApi = createVisualElectronApi(createVisualFixture("dense"), "?history-state=error");
await expect(errorApi.getHistory()).resolves.toHaveLength(2);
await expect(errorApi.getHistory()).rejects.toThrow("Visual history load failed");
});
});
const productionEntry: HistoryEntry = {
...entry({ id: "production", name: "Produktiv" }),
status: "completed"
};
void productionEntry;
+87
View File
@@ -0,0 +1,87 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { OverlayHost } from "../src/renderer/shell/OverlayHost";
import { UpdateExperience } from "../src/renderer/shell/UpdateExperience";
describe("OverlayHost", () => {
it("renders every desktop overlay slot exactly once", () => {
const slots = {
confirm: <span>confirm-slot</span>,
onlineBackup: <span>backup-slot</span>,
diagnostics: <span>diagnostics-slot</span>,
deleteConfirmation: <span>delete-slot</span>,
conflict: <span>conflict-slot</span>,
accountCreate: <span>account-create-slot</span>,
accountEdit: <span>account-edit-slot</span>,
keyStats: <span>key-stats-slot</span>,
linkPopup: <span>link-popup-slot</span>,
update: <span>update-slot</span>,
toast: <span>toast-slot</span>,
accountContextMenu: <span>account-menu-slot</span>,
downloadContextMenu: <span>download-menu-slot</span>,
columnContextMenu: <span>column-menu-slot</span>,
historyContextMenu: <span>history-menu-slot</span>,
dropOverlay: <span>drop-slot</span>
};
const html = renderToStaticMarkup(<OverlayHost {...slots} />);
expect(html).toContain("id=\"md-overlay-host\"");
for (const value of Object.values(slots)) {
const label = String(value.props.children);
expect(html.split(label)).toHaveLength(2);
}
});
it("does not render placeholders for empty slots", () => {
const html = renderToStaticMarkup(<OverlayHost toast={<span>sichtbar</span>} />);
expect(html).toContain("sichtbar");
expect(html).not.toContain("data-overlay-slot");
});
it("hosts the update surface through the shared dialog without duplicating the trigger", () => {
const html = renderToStaticMarkup(
<OverlayHost
update={(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
onClose={() => {}}
onInstall={() => {}}
onLater={() => {}}
onOpen={() => {}}
open
progress={0}
releaseNotes="Changes"
renderTrigger={false}
state="prompt"
/>
)}
/>
);
expect(html).toContain("md-dialog-size-update");
expect(html).not.toContain("aria-label=\"Update verfügbar\"");
expect(html.match(/role=\"dialog\"/g)).toHaveLength(1);
});
it("defines a stacking-context-safe menu, tooltip, toast and modal layer order", () => {
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(css).toMatch(/--md-layer-menu:\s*600/);
expect(css).toMatch(/--md-layer-tooltip:\s*700/);
expect(css).toMatch(/--md-layer-toast:\s*800/);
expect(css).toMatch(/--md-layer-modal:\s*1000/);
expect(css).toMatch(/\.md-context-menu\s*\{[^}]*z-index:\s*var\(--md-layer-menu\)/s);
expect(css).toMatch(/\.md-update-tooltip\s*\{[^}]*z-index:\s*var\(--md-layer-tooltip\)/s);
expect(css).toMatch(/\.md-toast\s*\{[^}]*z-index:\s*var\(--md-layer-toast\)/s);
expect(css).toMatch(/\.md-dialog-backdrop\s*\{[^}]*z-index:\s*var\(--md-layer-modal\)/s);
expect(css).toMatch(/\.md-drop-overlay\s*\{[^}]*pointer-events:\s*none/s);
expect(css).toMatch(/\.md-overlay-host \.md-dialog-backdrop\s*\{[^}]*z-index:\s*var\(--md-layer-modal\)/s);
expect(css).toMatch(/\.md-overlay-host \.md-context-menu\s*\{[^}]*z-index:\s*var\(--md-layer-menu\)/s);
expect(css).toMatch(/\.md-overlay-host \.md-toast\s*\{[^}]*z-index:\s*var\(--md-layer-toast\)/s);
expect(css).toMatch(/\.md-overlay-host \.md-dialog\s*\{[^}]*background:\s*var\(--ui-surface\)/s);
});
});
+175
View File
@@ -0,0 +1,175 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ErrorBoundary } from "../src/renderer/error-boundary";
import { AppShell } from "../src/renderer/shell/AppShell";
import * as focusModule from "../src/renderer/ui/focus";
import * as shellModel from "../src/renderer/shell/shell-model";
describe("responsive shell mode", () => {
it("selects full, compact and minimum modes at every boundary", () => {
const getResponsiveShellMode = (shellModel as unknown as {
getResponsiveShellMode?: (width: number) => "full" | "compact" | "minimum";
}).getResponsiveShellMode;
expect(getResponsiveShellMode).toBeTypeOf("function");
expect([
getResponsiveShellMode!(2560),
getResponsiveShellMode!(1920),
getResponsiveShellMode!(1367),
getResponsiveShellMode!(1366),
getResponsiveShellMode!(1121),
getResponsiveShellMode!(1120)
]).toEqual(["full", "full", "full", "compact", "compact", "minimum"]);
});
it("wires the derived mode into stable shell markup and responsive CSS", () => {
const html = renderToStaticMarkup(
<AppShell
activeView="downloads"
contextInfo={null}
footer={null}
headerActions={null}
onSidebarCollapsedChange={() => {}}
onViewChange={() => {}}
sidebar={<div>Filter</div>}
sidebarCollapsed={false}
sidebarStatus={null}
toolbar={null}
>
<div>Downloads</div>
</AppShell>
);
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(html).toContain('data-responsive-mode="full"');
expect(html).toContain("md-shell is-full");
expect(css).toContain(".md-shell.is-compact");
expect(css).toContain(".md-shell.is-minimum");
expect(css).toMatch(/grid-template-columns:\s*56px minmax\(0,\s*1fr\)/);
});
it("keeps responsive rail content hidden behind a visible expand control", () => {
const shellSource = readFileSync(new URL("../src/renderer/shell/AppShell.tsx", import.meta.url), "utf8");
const sidebarSource = readFileSync(new URL("../src/renderer/shell/AppSidebar.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(shellSource).toContain("responsiveRail={responsiveSidebarCollapsed}");
expect(sidebarSource).toContain('is-responsive-rail');
expect(css).toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-toggle\s*\{[^}]*width:\s*32px;[^}]*height:\s*32px;[^}]*opacity:\s*1;/s);
expect(css).not.toMatch(/\.md-shell-sidebar\.is-responsive-rail \.md-shell-sidebar-scroll\s*\{[^}]*visibility:\s*visible;/s);
});
});
describe("focus restoration", () => {
it("restores the preferred connected target after the closing render", () => {
const restoreFocus = (focusModule as unknown as {
restoreFocus?: (
preferredTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null,
schedule: (callback: () => void) => void
) => void;
}).restoreFocus;
let scheduled: (() => void) | null = null;
let preferredFocusCount = 0;
let fallbackFocusCount = 0;
const preferredTarget = {
isConnected: true,
focus: () => {
preferredFocusCount += 1;
}
} as HTMLElement;
const fallbackTarget = {
isConnected: true,
focus: () => {
fallbackFocusCount += 1;
}
} as HTMLElement;
expect(restoreFocus).toBeTypeOf("function");
restoreFocus!(preferredTarget, fallbackTarget, (callback) => {
scheduled = callback;
});
expect(preferredFocusCount).toBe(0);
expect(fallbackFocusCount).toBe(0);
(scheduled as (() => void) | null)?.();
expect(preferredFocusCount).toBe(1);
expect(fallbackFocusCount).toBe(0);
});
it("uses only a connected fallback when the preferred target was removed", () => {
const restoreFocus = (focusModule as unknown as {
restoreFocus?: (
preferredTarget: HTMLElement | null,
fallbackTarget: HTMLElement | null,
schedule: (callback: () => void) => void
) => void;
}).restoreFocus;
let scheduled: (() => void) | null = null;
let preferredConnected = true;
let preferredFocusCount = 0;
let fallbackFocusCount = 0;
const preferredTarget = {
get isConnected() {
return preferredConnected;
},
focus: () => {
preferredFocusCount += 1;
}
} as HTMLElement;
const fallbackTarget = {
isConnected: true,
focus: () => {
fallbackFocusCount += 1;
}
} as HTMLElement;
restoreFocus!(preferredTarget, fallbackTarget, (callback) => {
scheduled = callback;
});
preferredConnected = false;
(scheduled as (() => void) | null)?.();
expect(preferredFocusCount).toBe(0);
expect(fallbackFocusCount).toBe(1);
restoreFocus!(null, { ...fallbackTarget, isConnected: false } as HTMLElement, (callback) => callback());
expect(fallbackFocusCount).toBe(1);
});
it("is consumed by dialogs, context menus and the avatar menu", () => {
const consumers = [
["../src/renderer/ui/Dialog.tsx", 'from "./focus"'],
["../src/renderer/ui/ContextMenu.tsx", 'from "./focus"'],
["../src/renderer/shell/AvatarMenu.tsx", 'from "../ui/focus"']
] as const;
for (const [path, importPath] of consumers) {
const source = readFileSync(new URL(path, import.meta.url), "utf8");
expect(source).toContain("restoreFocus");
expect(source).toContain(importPath);
expect(source).toMatch(/restoreFocus\(/);
}
});
});
describe("renderer error boundary", () => {
it("renders a tokenized accessible recovery surface", () => {
const boundary = new ErrorBoundary({ children: "content" });
boundary.state = { hasError: true, message: "Render failure" };
const html = renderToStaticMarkup(boundary.render());
expect(html).toContain('class="ui-error-boundary"');
expect(html).toContain('role="alert"');
expect(html).toContain('aria-labelledby="renderer-error-title"');
expect(html).toContain('aria-describedby="renderer-error-description renderer-error-details"');
expect(html).toContain('id="renderer-error-title"');
expect(html).toContain('id="renderer-error-description"');
expect(html).toContain('id="renderer-error-details"');
expect(html).toContain("Render failure");
expect(html).toContain("Oberfläche neu laden");
expect(html).not.toContain("style=");
});
});
+632
View File
@@ -0,0 +1,632 @@
import { readFileSync } from "node:fs";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
applyAccountEdit,
createAccountEditState,
type AccountEditTarget
} from "../src/renderer/account-edit";
import {
buildBulkAccountEnabledState,
buildConfiguredProviderOrder
} from "../src/renderer/account-ui";
import { getDebridLinkApiKeyId } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import {
ACCOUNT_COLUMNS,
SETTINGS_SECTIONS,
buildAccountRowId,
buildTargetedAccountCheck,
filterAccountAddOptions,
getSettingsSaveLabel,
projectAccountRows,
pruneAccountSelection,
reconcileAccountAddDraft,
sortAccountRows,
type AccountAddOption,
type AccountRowSource,
type SettingsFormViewModel
} from "../src/renderer/views/settings/settings-model";
import {
AccountAddDialog,
AccountEditDialog,
AccountWorkspace,
type AccountWorkspaceActions,
type AccountWorkspaceViewModel
} from "../src/renderer/views/settings/AccountWorkspace";
import { SettingsForm } from "../src/renderer/views/settings/SettingsForm";
import {
SettingsContent,
SettingsSidebar,
SettingsView,
type SettingsViewActions,
type SettingsViewModel
} from "../src/renderer/views/settings/SettingsView";
const GIB = 1024 * 1024 * 1024;
const NOW = 1_700_000_000_000;
const appSource = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
const accountWorkspaceSource = readFileSync(
new URL("../src/renderer/views/settings/AccountWorkspace.tsx", import.meta.url),
"utf8"
);
function sourceBlock(source: string, start: string, end: string): string {
return source.slice(source.indexOf(start), source.indexOf(end, source.indexOf(start)));
}
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) {
return;
}
visit(node);
visitElements(node.props.children, visit);
visitElements(node.props.actions, visit);
}
function findElement(node: ReactNode, predicate: (element: ReactElement) => boolean): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && predicate(element)) {
result = element;
}
});
if (!result) {
throw new Error("Element not found");
}
return result;
}
function count(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
function accountSources(): AccountRowSource[] {
return [
{
identityId: "mega-premium",
service: "megadebrid-api",
hoster: "Mega-Debrid",
mode: "API",
icon: "./provider-icons/mega-debrid.png",
enabled: true,
status: {
state: "premium",
message: "Premium aktiv",
premiumUntilMs: NOW + 7 * 24 * 60 * 60 * 1000,
email: "verified@example.test"
},
dailyLimitBytes: 10 * GIB,
dailyUsageBytes: 4 * GIB,
username: "stored@example.test",
credentialKind: "password",
canCheck: true
},
{
identityId: "debrid-free",
service: "debridlink",
hoster: "Debrid-Link",
mode: "API-Key",
icon: "./provider-icons/debrid-link.ico",
enabled: true,
status: { state: "free", message: "Free Account", premiumUntilMs: null },
dailyLimitBytes: 0,
dailyUsageBytes: 0,
username: "free-user",
credentialKind: "api-key",
canCheck: true
},
{
identityId: "invalid",
service: "ddownload",
hoster: "DDownload",
mode: "Login",
icon: "./provider-icons/ddownload.ico",
enabled: true,
status: { state: "invalid", message: "Login abgelehnt", premiumUntilMs: null },
username: "invalid@example.test",
credentialKind: "password",
canCheck: false
},
{
identityId: "unknown",
service: "onefichier",
hoster: "1Fichier",
mode: "API",
icon: "./provider-icons/onefichier.png",
enabled: true,
status: { state: "unchecked", message: "", premiumUntilMs: null },
username: "—",
credentialKind: "api-key",
canCheck: false
},
{
identityId: "disabled",
service: "linksnappy",
hoster: "LinkSnappy",
mode: "Web-Login",
icon: "./provider-icons/linksnappy.png",
enabled: false,
status: { state: "disabled", message: "", premiumUntilMs: null },
username: "disabled@example.test",
credentialKind: "password",
canCheck: false
}
];
}
function accountOptions(): AccountAddOption[] {
return [
{
id: "realdebrid-api",
service: "realdebrid",
title: "Real-Debrid",
mode: "API",
description: "API-Token verwenden",
functionLabel: "API-Token",
filter: "api",
multi: false
},
{
id: "ddownload-login",
service: "ddownload",
title: "DDownload",
mode: "Login",
description: "Login und Passwort",
functionLabel: "Login:Passwort",
filter: "web",
multi: false
},
{
id: "megadebrid-api",
service: "megadebrid-api",
title: "Mega-Debrid",
mode: "API",
description: "Weiteren Account hinzufügen",
functionLabel: "Login:Passwort",
filter: "api",
multi: true
},
{
id: "debridlink-api",
service: "debridlink",
title: "Debrid-Link",
mode: "API",
description: "Weiteren API-Key hinzufügen",
functionLabel: "API-Key",
filter: "api",
multi: true
}
];
}
function formModel(): SettingsFormViewModel {
return {
title: "Allgemein",
description: "Grundeinstellungen der Anwendung.",
groups: [
{
id: "appearance",
title: "Darstellung",
fields: [
{
id: "downloadDir",
kind: "path",
label: "Download-Ordner",
value: "C:\\Downloads",
help: "Zielordner für Downloads."
},
{
id: "theme",
kind: "theme",
label: "Theme",
value: "dark",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "system", label: "System" }
]
},
{
id: "autoUpdate",
kind: "switch",
label: "Automatisch nach Updates suchen",
value: true
}
]
}
]
};
}
function workspaceModel(): AccountWorkspaceViewModel {
return {
activePanel: "overview",
rows: projectAccountRows(accountSources(), [buildAccountRowId("megadebrid-api", "API", "mega-premium")], NOW),
selectedIds: [buildAccountRowId("megadebrid-api", "API", "mega-premium")],
busy: false,
rules: {
providerOrder: ["Debrid-Link", "Real-Debrid"],
routing: ["rapidgator.net → Debrid-Link"],
autoFallback: true
}
};
}
function workspaceActions(overrides: Partial<AccountWorkspaceActions> = {}): AccountWorkspaceActions {
return {
onPanelChange: () => {},
onSelect: () => {},
onToggleEnabled: () => {},
onEdit: () => {},
onContextMenu: () => {},
onAdd: () => {},
onRemoveSelected: () => {},
onCheckAll: () => {},
...overrides
};
}
function viewModel(saveState: SettingsViewModel["saveState"] = "clean"): SettingsViewModel {
return {
section: "accounts",
saveState,
form: formModel(),
accounts: workspaceModel()
};
}
function viewActions(): SettingsViewActions {
return {
onSectionChange: () => {},
onSave: () => {},
form: { onChange: () => {}, onAction: () => {} },
accounts: workspaceActions()
};
}
describe("settings model", () => {
it("keeps six stable sections and accessible save-state labels", () => {
expect(SETTINGS_SECTIONS).toEqual([
{ id: "allgemein", label: "Allgemein" },
{ id: "accounts", label: "Accounts" },
{ id: "extract", label: "Entpacken" },
{ id: "speed", label: "Geschwindigkeit" },
{ id: "cleanup", label: "Bereinigung" },
{ id: "updates", label: "Updates" }
]);
expect(["clean", "dirty", "saving", "saved", "error"].map((state) => getSettingsSaveLabel(state as never)))
.toEqual(["Gespeichert", "Ungespeicherte Änderungen", "Wird gespeichert…", "Gespeichert", "Speichern fehlgeschlagen"]);
});
it("projects stable sanitized rows with full verified usernames and distinct states", () => {
const rows = projectAccountRows(accountSources(), [], NOW);
expect(rows.map((row) => row.id)).toEqual(accountSources().map((source) => buildAccountRowId(source.service, source.mode, source.identityId)));
expect(rows[0].username).toBe("verified@example.test");
expect(rows[0].credential).toBe("••••••");
expect(rows[1].credential).toBe("API-Key");
expect(rows.map((row) => row.status.tone)).toEqual(["ok", "free", "invalid", "unknown", "disabled"]);
expect(rows.map((row) => row.status.text)).toEqual([
"Premium aktiv",
"Free Account",
"Login abgelehnt",
"Noch nicht geprüft",
"Deaktiviert"
]);
expect(JSON.stringify(rows)).not.toContain("test-password");
expect(JSON.stringify(rows)).not.toContain("test-token");
});
it("sorts positive premium expirations first and prunes vanished selections", () => {
const rows = projectAccountRows(accountSources(), [], NOW);
const sorted = sortAccountRows(rows);
expect(sorted[0].status.tone).toBe("ok");
expect(pruneAccountSelection([rows[0].id, "missing"], rows)).toEqual([rows[0].id]);
});
it("filters add options honestly and clears hidden credentials", () => {
const options = accountOptions();
expect(filterAccountAddOptions(options, "", "web", []) .map((option) => option.id)).toEqual(["ddownload-login"]);
expect(filterAccountAddOptions(options, "api-key", "all", ["realdebrid"]).map((option) => option.id)).toEqual(["debridlink-api"]);
expect(filterAccountAddOptions(options, "", "all", ["realdebrid"]).map((option) => option.id)).not.toContain("realdebrid-api");
expect(filterAccountAddOptions(options, "", "all", ["realdebrid"]).map((option) => option.id)).toContain("megadebrid-api");
expect(reconcileAccountAddDraft({
selectedId: "realdebrid-api",
login: "member@example.test",
password: "test-password",
token: "test-token",
dailyLimitGb: "10"
}, [options[1]])).toEqual({ selectedId: null, login: "", password: "", token: "", dailyLimitGb: "" });
});
it("targets new Mega and Debrid-Link identities without inventing checks for other services", () => {
const options = accountOptions();
expect(buildTargetedAccountCheck(options[2], "mda-new")).toEqual({ service: "megadebrid-api", expectedStatusId: "mda-new" });
expect(buildTargetedAccountCheck(options[3], "dlk-new")).toEqual({ service: "debridlink", expectedStatusId: "dlk-new" });
expect(buildTargetedAccountCheck(options[1], "ddownload-new")).toBeNull();
});
it("preserves provider order and deduplicates bulk account identities", () => {
expect(buildConfiguredProviderOrder(
["debridlink", "realdebrid", "alldebrid"],
["realdebrid", "alldebrid", "debridlink", "bestdebrid"]
)).toEqual(["debridlink", "realdebrid", "alldebrid", "bestdebrid"]);
expect(buildBulkAccountEnabledState(
["alldebrid"],
["megadebrid-api", "alldebrid"],
["mega-1", "mega-1"],
["dl-1", "dl-1"],
false
)).toEqual({
disabledProviders: ["alldebrid", "megadebrid-api"],
megaDebridDisabledAccountIds: ["mega-1"],
debridLinkDisabledKeyIds: ["dl-1"]
});
});
it("keeps exact rounded limits and migrates edited identity metadata", () => {
const login = "member@example.test";
const oldId = getMegaDebridAccountId(login);
const newLogin = "renamed@example.test";
const newId = getMegaDebridAccountId(newLogin);
const exactLimit = Math.floor(10.05 * GIB);
const settings = {
...defaultSettings(),
megaCredentials: `${login}:test-password`,
megaLogin: login,
megaPassword: "test-password",
megaDebridDisabledAccountIds: [oldId],
megaDebridAccountDailyLimitBytes: { [oldId]: exactLimit },
megaDebridAccountDailyUsageBytes: { [oldId]: 2 * GIB },
megaDebridAccountTotalUsageBytes: { [oldId]: 20 * GIB }
};
const target: AccountEditTarget = {
type: "mega",
rowKey: "row",
kind: "megadebrid-api",
service: "megadebrid-api",
accountId: oldId
};
const unchanged = applyAccountEdit(settings, createAccountEditState(target, settings));
const renamed = applyAccountEdit(settings, {
...createAccountEditState(target, settings),
login: newLogin
});
expect(unchanged.megaDebridAccountDailyLimitBytes[oldId]).toBe(exactLimit);
expect(renamed.megaDebridDisabledAccountIds).toEqual([newId]);
expect(renamed.megaDebridAccountDailyLimitBytes[newId]).toBe(exactLimit);
expect(renamed.megaDebridAccountDailyUsageBytes[newId]).toBeUndefined();
expect(renamed.megaDebridAccountTotalUsageBytes[newId]).toBeUndefined();
});
});
describe("settings views", () => {
it("renders one real sidebar marker and all sections", () => {
const html = renderToStaticMarkup(<SettingsSidebar actions={viewActions()} model={viewModel()} />);
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
for (const section of SETTINGS_SECTIONS) {
expect(html).toContain(section.label);
}
expect(html).toContain("aria-current=\"page\"");
});
it("shows every save state without a generic toolbar, pagination or info control", () => {
for (const saveState of ["clean", "dirty", "saving", "saved", "error"] as const) {
const html = renderToStaticMarkup(<SettingsContent actions={viewActions()} model={viewModel(saveState)} />);
expect(html).toContain(getSettingsSaveLabel(saveState));
expect(html).toContain("Einstellungen speichern");
expect(html).not.toContain("role=\"toolbar\"");
expect(html).not.toContain("table-pagination");
expect(html).not.toContain("ui-context-info");
}
});
it("renders the sidebar and content once in the complete view", () => {
const html = renderToStaticMarkup(<SettingsView actions={viewActions()} model={viewModel()} />);
expect(count(html, "data-visual-region=\"settings-sidebar\"")).toBe(1);
expect(count(html, "data-visual-region=\"accounts-table-body\"")).toBe(1);
});
it("renders form controls, theme choices and switches through bounded callbacks", () => {
let changed = "";
const form = SettingsForm({
model: formModel(),
actions: {
onChange: (id) => { changed = id; },
onAction: () => {}
}
});
const html = renderToStaticMarkup(form);
expect(html).toContain("Light");
expect(html).toContain("Dark");
expect(html).toContain("System");
expect(html).toContain("role=\"switch\"");
const switchButton = findElement(form, (element) => element.props.role === "switch");
switchButton.props.onClick();
expect(changed).toBe("autoUpdate");
});
});
describe("account workspace", () => {
it("renders the exact columns, one table marker, full usernames and no raw credentials", () => {
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
const positions = ACCOUNT_COLUMNS.map((column) => html.indexOf(column));
expect(positions.every((position) => position >= 0)).toBe(true);
expect(positions).toEqual([...positions].sort((a, b) => a - b));
expect(count(html, "data-visual-region=\"accounts-table-body\"")).toBe(1);
expect(html).toContain("verified@example.test");
expect(html).not.toContain("ve***st");
expect(html).toContain("••••••");
expect(html).not.toContain("test-password");
expect(html).not.toContain("test-token");
expect(html).not.toContain("table-pagination");
expect(html).not.toContain("role=\"toolbar\"");
});
it("keeps row selection, enable toggles, edit and context actions separate", () => {
const calls: string[] = [];
const tree = AccountWorkspace({
model: workspaceModel(),
actions: workspaceActions({
onSelect: (id) => calls.push(`select:${id}`),
onToggleEnabled: (id) => calls.push(`toggle:${id}`),
onEdit: (id) => calls.push(`edit:${id}`),
onContextMenu: (id) => calls.push(`context:${id}`)
})
});
const row = findElement(tree, (element) => element.props.role === "row" && element.props["aria-selected"] === true);
const checkbox = findElement(row, (element) => element.type === "input" && element.props.type === "checkbox");
const actionButton = findElement(row, (element) => element.type === "button" && String(element.props["aria-label"] || "").includes("Aktionen"));
const rowId = workspaceModel().rows[0].id;
row.props.onClick({ target: { role: "cell" }, currentTarget: row });
row.props.onKeyDown({ key: "Enter", target: row, currentTarget: row, preventDefault: () => {} });
row.props.onKeyDown({ key: " ", target: checkbox, currentTarget: row, preventDefault: () => {} });
checkbox.props.onChange();
row.props.onDoubleClick();
actionButton.props.onClick({ stopPropagation: () => {}, currentTarget: { getBoundingClientRect: () => ({ right: 20, bottom: 30 }) } });
expect(calls).toEqual([
`select:${rowId}`,
`select:${rowId}`,
`toggle:${rowId}`,
`edit:${rowId}`,
`context:${rowId}`
]);
});
it("keeps overview and rules in the same workspace while only one panel is active", () => {
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
expect(count(html, "class=\"settings-account-panel\"")).toBe(2);
expect(count(html, "hidden=\"\"")).toBe(1);
expect(html).toContain("Provider-Reihenfolge");
expect(html).toContain("Hoster-Routing");
expect(html).toContain("Automatischer Fallback");
});
it("keeps add and edit dialogs separate and every secret field protected", () => {
const addHtml = renderToStaticMarkup(
<AccountAddDialog
actions={{
onQueryChange: () => {},
onFilterChange: () => {},
onOptionSelect: () => {},
onFieldChange: () => {},
onClose: () => {},
onSubmit: () => {}
}}
model={{
open: true,
query: "",
filter: "all",
options: accountOptions(),
selectedOptionId: "megadebrid-api",
fields: [
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
{ id: "password", label: "Passwort", type: "password", value: "test-password" }
],
error: "",
busy: false
}}
/>
);
const editHtml = renderToStaticMarkup(
<AccountEditDialog
actions={{
onFieldChange: () => {},
onClose: () => {},
onCheck: () => {},
onSave: () => {},
onRemove: () => {},
onToggleEnabled: () => {}
}}
model={{
open: true,
hoster: "Mega-Debrid",
mode: "API",
identity: "member@example.test",
enabled: true,
fields: [
{ id: "login", label: "Login", type: "text", value: "member@example.test" },
{ id: "password", label: "Passwort", type: "password", value: "test-password" },
{ id: "token", label: "Token", type: "password", value: "test-token" }
],
error: "",
busy: false
}}
/>
);
expect(addHtml).toContain("Account hinzufügen");
expect(addHtml).toContain("Prüfen und speichern");
expect(addHtml).toContain("Alle");
expect(addHtml).toContain("API");
expect(addHtml).toContain("Web");
expect(editHtml).toContain("Account bearbeiten");
expect(editHtml).toContain("member@example.test");
expect(editHtml).toContain("Entfernen");
expect(editHtml).toContain("Prüfen");
expect(count(addHtml, "type=\"password\"")).toBe(1);
expect(count(editHtml, "type=\"password\"")).toBe(2);
});
});
describe("settings App integration", () => {
it("keeps specific persistence revision-safe when the draft changes in flight", () => {
const block = sourceBlock(appSource, "const persistSpecificSettings", "const runAccountQuickAction");
expect(block).toContain("revisionAtStart");
expect(block).toContain("mergeConcurrentSpecificSettings");
expect(block).toContain('setSettingsSaveState("dirty")');
});
it("keeps unchecked single accounts honest without a positive status", () => {
const block = sourceBlock(appSource, "const accountSources", "const selectedAccountViewId");
expect(block).toMatch(/:\s*!checkedStatus\s*\?\s*"unchecked"/s);
});
it("stores only the stable account row id in context-menu state", () => {
const stateBlock = sourceBlock(appSource, "interface AccountContextMenuState", "function getAccountQuickActionMeta");
expect(stateBlock).toContain("rowId: string");
expect(stateBlock).not.toContain("row: AccountTableRow");
expect(appSource).toContain("activeAccountContextRow");
});
it("preserves the System theme choice while applying its resolved palette", () => {
expect(appSource).toContain("settingsThemeChoice");
expect(appSource).toContain("resolveSettingsThemeChoice");
});
});
describe("settings geometry", () => {
it("keeps the specified form, table, switch, overflow and selection geometry", () => {
const css = readFileSync(new URL("../src/renderer/views/settings/settings.css", import.meta.url), "utf8");
expect(css).toMatch(/\.settings-content\s*{[^}]*padding:\s*24px;/s);
expect(css).toMatch(
/\.md-runtime-view-content\s*>\s*\.settings-content\s*{[^}]*height:\s*100%;[^}]*padding:\s*24px;/s
);
expect(css).toMatch(/\.settings-form-column\s*{[^}]*width:\s*500px;[^}]*max-width:\s*100%;/s);
expect(css).toMatch(/\.settings-control\s*{[^}]*height:\s*44px;[^}]*border-radius:\s*6px;/s);
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
expect(css).toMatch(/\.settings-account-row\s*{[^}]*height:\s*48px;/s);
expect(css).toMatch(/\.settings-account-table-body\s*{[^}]*overflow:\s*auto;/s);
expect(accountWorkspaceSource).toContain("onScroll={syncAccountTableScroll}");
expect(css).toMatch(/\.settings-view\s*{[^}]*min-width:\s*0;/s);
expect(css).toMatch(/\.settings-account-workspace\s*{[^}]*width:\s*100%;[^}]*min-width:\s*0;[^}]*min-height:\s*0;/s);
expect(css).toMatch(/\.settings-static\s*{[^}]*user-select:\s*none;/s);
expect(css).toMatch(/\.settings-content\s+:where\(input,\s*textarea,\s*\[contenteditable="true"\],\s*\.settings-copyable\)\s*{[^}]*user-select:\s*text;/s);
});
});
+399
View File
@@ -0,0 +1,399 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
import { readBandwidthChartPalette } from "../src/renderer/App";
import {
buildStatisticsViewModel,
type StatisticsMetric,
type StatisticsRange
} from "../src/renderer/views/statistics/statistics-model";
import {
StatisticsContent,
StatisticsSidebar,
StatisticsView,
type StatisticsViewActions
} from "../src/renderer/views/statistics/StatisticsView";
import { createVisualFixture } from "./visual/fixtures";
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
function createSnapshot(): UiSnapshot {
return structuredClone(createVisualFixture("empty").snapshot);
}
function item(
id: string,
status: DownloadStatus,
overrides: Partial<DownloadItem> = {}
): DownloadItem {
return {
id,
packageId: "statistics-package",
url: `https://url-host-${id}.example/file`,
provider: "realdebrid",
providerLabel: "Real-Debrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: 100,
totalBytes: 100,
progressPercent: status === "completed" ? 100 : 50,
fileName: `${id}.bin`,
targetPath: `C:\\Downloads\\${id}.bin`,
resumable: true,
attempts: 1,
lastError: status === "failed" ? "Fehlgeschlagen" : "",
fullStatus: status,
createdAt: now - 1000,
updatedAt: now,
...overrides
};
}
function setItems(snapshot: UiSnapshot, items: DownloadItem[]): void {
snapshot.session.items = Object.fromEntries(items.map((entry) => [entry.id, entry]));
}
function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void {
if (Array.isArray(node)) {
node.forEach((child) => visitElements(child, visit));
return;
}
if (!isValidElement(node)) {
return;
}
visit(node);
visitElements(node.props.children, visit);
}
function findButton(node: ReactNode, label: string): ReactElement {
let result: ReactElement | null = null;
visitElements(node, (element) => {
if (!result && element.type === "button" && element.props.children === label) {
result = element;
}
});
if (!result) {
throw new Error(`Button not found: ${label}`);
}
return result;
}
function createActions(overrides: Partial<StatisticsViewActions> = {}): StatisticsViewActions {
return {
onRangeChange: () => {},
onResetSession: () => {},
onResetAll: () => {},
onResetErrors: () => {},
...overrides
};
}
function expectUnavailable(metric: StatisticsMetric): void {
expect(metric).toMatchObject({ value: null, available: false });
expect(metric.sourceLabel.trim()).not.toBe("");
}
describe("statistics model", () => {
it("uses real snapshot session fields and excludes active or waiting downloads from the success denominator", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 600;
snapshot.stats.totalFilesSession = 3;
snapshot.session.running = true;
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed"),
item("active", "downloading"),
item("waiting", "queued")
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.metrics.downloadedBytes.value).toBe(600);
expect(model.metrics.files.value).toBe(3);
expect(model.metrics.successRate.value).toBe(75);
expect(model.metrics.errors.value).toBe(1);
});
it("reports no success rate when the current queue has no completed or failed result", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [item("active", "downloading"), item("waiting", "queued")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expectUnavailable(model.metrics.successRate);
expect(model.metrics.errors).toMatchObject({ value: 0, available: true });
});
it("sorts current-queue providers by bytes and then id without deriving them from URL hostnames", () => {
const snapshot = createSnapshot();
snapshot.session.running = true;
setItems(snapshot, [
item("real", "completed", {
url: "https://alldebrid.invalid/wrong-source",
provider: "realdebrid",
providerLabel: "Real-Debrid Konto",
downloadedBytes: 200
}),
item("all", "failed", {
url: "https://realdebrid.invalid/wrong-source",
provider: "alldebrid",
providerLabel: "AllDebrid",
downloadedBytes: 200
}),
item("link", "completed", {
url: "https://realdebrid.invalid/also-wrong",
provider: "debridlink",
providerLabel: "Debrid-Link",
downloadedBytes: 400
}),
item("unknown", "completed", {
url: "https://hoster-only.invalid/not-a-provider",
provider: null,
providerLabel: undefined,
downloadedBytes: 900
})
]);
const model = buildStatisticsViewModel(snapshot, "session", now);
expect(model.providers.map((row) => row.id)).toEqual(["debridlink", "alldebrid", "realdebrid"]);
expect(model.providers.map((row) => row.label)).toEqual(["Debrid-Link", "AllDebrid", "Real-Debrid Konto"]);
expect(model.providers.map((row) => [row.completed, row.failed])).toEqual([[1, 0], [0, 1], [1, 0]]);
expect(model.providers.some((row) => row.id.includes("host"))).toBe(false);
});
it("uses daily provider usage only for the matching local day", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 999_999;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 500, alldebrid: 1_500 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 2_000, available: true });
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["alldebrid", 1_500],
["realdebrid", 500]
]);
expectUnavailable(model.metrics.files);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
});
it("treats a stale daily key as a genuine zero today without stale provider rows", () => {
const snapshot = createSnapshot();
snapshot.settings.providerDailyUsageDay = "2026-08-09";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 900 };
const model = buildStatisticsViewModel(snapshot, "today", now);
expect(model.metrics.downloadedBytes).toMatchObject({ value: 0, available: true });
expect(model.providers).toEqual([]);
});
it.each(["week", "month"] satisfies StatisticsRange[])("keeps %s unavailable without inventing historical buckets", (range) => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 5_000;
snapshot.stats.totalDownloadedAllTime = 50_000;
snapshot.settings.providerDailyUsageDay = "2026-08-10";
snapshot.settings.providerDailyUsageBytes = { realdebrid: 4_000 };
snapshot.settings.providerTotalUsageBytes = { realdebrid: 40_000 };
const model = buildStatisticsViewModel(snapshot, range, now);
expect(model.coverage).toBe("unavailable");
expect(model.message).toBe("Für diesen Zeitraum werden noch keine historischen Daten gespeichert.");
expect(model.providers).toEqual([]);
expect(model.providerScope).toBeNull();
Object.values(model.metrics).forEach(expectUnavailable);
});
it("uses all-time counters and provider totals without inventing historical outcomes", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloadedAllTime = 25_000;
snapshot.stats.totalFilesAllTime = 42;
snapshot.settings.providerTotalUsageBytes = { realdebrid: 5_000, debridlink: 20_000 };
snapshot.summary = {
total: 10,
success: 9,
failed: 1,
cancelled: 0,
extracted: 9,
durationSeconds: 10,
averageSpeedBps: 2_500
};
const model = buildStatisticsViewModel(snapshot, "all", now);
expect(model.metrics.downloadedBytes.value).toBe(25_000);
expect(model.metrics.files.value).toBe(42);
expect(model.providers.map((row) => [row.id, row.bytes])).toEqual([
["debridlink", 20_000],
["realdebrid", 5_000]
]);
expect(model.providers.every((row) => row.completed === null && row.failed === null)).toBe(true);
expectUnavailable(model.metrics.successRate);
expectUnavailable(model.metrics.errors);
expectUnavailable(model.metrics.averageSpeedBps);
});
it("prefers live queue outcomes over an old summary and uses the summary only after the run ends", () => {
const snapshot = createSnapshot();
setItems(snapshot, [
item("complete-a", "completed"),
item("complete-b", "completed"),
item("complete-c", "completed"),
item("failed", "failed")
]);
snapshot.summary = {
total: 4,
success: 1,
failed: 3,
cancelled: 0,
extracted: 1,
durationSeconds: 100,
averageSpeedBps: 500
};
snapshot.session.running = true;
const active = buildStatisticsViewModel(snapshot, "session", now);
snapshot.session.running = false;
const ended = buildStatisticsViewModel(snapshot, "session", now);
expect(active.metrics.successRate.value).toBe(75);
expect(active.metrics.errors.value).toBe(1);
expectUnavailable(active.metrics.averageSpeedBps);
expect(ended.metrics.successRate.value).toBe(25);
expect(ended.metrics.errors.value).toBe(3);
expect(ended.metrics.averageSpeedBps).toMatchObject({ value: 500, available: true });
});
it("models empty, idle, active and paused session states separately", () => {
const empty = createSnapshot();
const idle = createSnapshot();
setItems(idle, [item("idle", "completed")]);
const active = createSnapshot();
active.session.running = true;
setItems(active, [item("active", "downloading")]);
const paused = createSnapshot();
paused.session.running = true;
paused.session.paused = true;
setItems(paused, [item("paused", "paused")]);
expect(buildStatisticsViewModel(empty, "session", now).sessionState).toBe("empty");
expect(buildStatisticsViewModel(idle, "session", now).sessionState).toBe("idle");
expect(buildStatisticsViewModel(active, "session", now).sessionState).toBe("active");
expect(buildStatisticsViewModel(paused, "session", now).sessionState).toBe("paused");
});
});
describe("statistics view", () => {
it("renders each statistics marker exactly once, all ranges and no download toolbar or pagination", () => {
const snapshot = createSnapshot();
snapshot.stats.totalDownloaded = 2_048;
snapshot.stats.totalFilesSession = 1;
setItems(snapshot, [item("complete", "completed")]);
const html = renderToStaticMarkup(
<StatisticsView
actions={createActions()}
chart={<div>Bestehender Bandbreitenverlauf</div>}
model={buildStatisticsViewModel(snapshot, "session", now)}
/>
);
for (const marker of ["statistics-sidebar", "statistics-kpis", "statistics-chart"]) {
expect(html.match(new RegExp(`data-visual-region=\\"${marker}\\"`, "g"))).toHaveLength(1);
}
for (const label of ["Sitzung", "Heute", "Sieben Tage", "30 Tage", "Gesamt"]) {
expect(html).toContain(`>${label}<`);
}
expect(html).toContain("Bestehender Bandbreitenverlauf");
expect(html).not.toContain("downloads-toolbar");
expect(html.toLocaleLowerCase("de-DE")).not.toContain("pagination");
});
it("dispatches range and reset controls only through the supplied callbacks", () => {
const snapshot = createSnapshot();
setItems(snapshot, [item("failed", "failed")]);
const model = buildStatisticsViewModel(snapshot, "session", now);
const calls: string[] = [];
const actions = createActions({
onRangeChange: (range) => calls.push(`range:${range}`),
onResetSession: () => calls.push("reset:session"),
onResetAll: () => calls.push("reset:all"),
onResetErrors: () => calls.push("reset:errors")
});
const sidebar = StatisticsSidebar({ actions, model });
const content = StatisticsContent({ actions, chart: <div />, model });
findButton(sidebar, "Heute").props.onClick();
findButton(content, "Sitzung zurücksetzen").props.onClick();
findButton(content, "Gesamt zurücksetzen").props.onClick();
findButton(content, "Fehler zurücksetzen").props.onClick();
expect(calls).toEqual(["range:today", "reset:session", "reset:all", "reset:errors"]);
});
it("enables error reset only for a positive genuine error metric", () => {
const clean = createSnapshot();
const failed = createSnapshot();
setItems(failed, [item("failed", "failed")]);
const cleanContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(clean, "session", now)
});
const failedContent = StatisticsContent({
actions: createActions(),
chart: <div />,
model: buildStatisticsViewModel(failed, "session", now)
});
expect(findButton(cleanContent, "Fehler zurücksetzen").props.disabled).toBe(true);
expect(findButton(failedContent, "Fehler zurücksetzen").props.disabled).toBe(false);
});
it("keeps the empty provider state inside the ARIA table as a row and spanning cell", () => {
const html = renderToStaticMarkup(
<StatisticsContent
actions={createActions()}
chart={<div />}
model={buildStatisticsViewModel(createSnapshot(), "session", now)}
/>
);
expect(html).toContain('class="statistics-provider-empty" role="row"');
expect(html).toContain('aria-colspan="3" role="cell"');
});
});
describe("bandwidth chart palette", () => {
it("requests only the semantic UI color properties and keeps the computed font family", () => {
const requested: string[] = [];
const values: Record<string, string> = {
"--ui-border": " rgb(61, 61, 61) ",
"--ui-text-muted": " rgb(145, 145, 145) ",
"--ui-accent": " rgb(56, 134, 255) "
};
const palette = readBandwidthChartPalette((property) => {
requested.push(property);
return values[property];
}, "Inter, Segoe UI, sans-serif");
expect(requested).toEqual(["--ui-border", "--ui-text-muted", "--ui-accent"]);
expect(palette).toEqual({
grid: "rgb(61, 61, 61)",
text: "rgb(145, 145, 145)",
accent: "rgb(56, 134, 255)",
fontFamily: "Inter, Segoe UI, sans-serif"
});
});
});
+17
View File
@@ -0,0 +1,17 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Toast } from "../src/renderer/ui/Toast";
describe("Toast", () => {
it("announces the current single toast politely", () => {
const html = renderToStaticMarkup(<Toast message="Einstellungen gespeichert" />);
expect(html).toContain("role=\"status\"");
expect(html).toContain("aria-live=\"polite\"");
expect(html).toContain("Einstellungen gespeichert");
});
it("renders nothing without a message", () => {
expect(renderToStaticMarkup(<Toast message="" />)).toBe("");
});
});
+184
View File
@@ -0,0 +1,184 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ContextInfoButton } from "../src/renderer/ui/ContextInfoButton";
import {
DataTable,
DataTableBody,
DataTableEmpty,
DataTableFooter,
DataTableHeader
} from "../src/renderer/ui/DataTable";
import { Icon } from "../src/renderer/ui/Icon";
import { Toolbar, ToolbarGroup, ToolbarSearch } from "../src/renderer/ui/Toolbar";
import { getThemeVariables, UI_FOCUS_RING_VARIABLE } from "../src/renderer/ui/theme";
const expectedThemes = {
dark: {
"--ui-canvas": "#0F0F0F",
"--ui-surface": "#232323",
"--ui-input": "#2B2B2B",
"--ui-table-header": "#313131",
"--ui-active": "#333436",
"--ui-hover": "#373535",
"--ui-tooltip": "#4F4D4D",
"--ui-border": "#3D3D3D",
"--ui-text": "#FFFFFF",
"--ui-text-secondary": "#EAEDF3",
"--ui-text-muted": "#919191",
"--ui-primary": "#BAD0FC",
"--ui-primary-hover": "#8AA5DC",
"--ui-accent": "#3886FF",
"--ui-warning": "#F1C786",
"--ui-danger": "#F06464",
"--ui-modal-secondary": "#35383D",
"--ui-overlay": "rgba(0, 0, 0, 0.60)"
},
light: {
"--ui-canvas": "#F3F4F6",
"--ui-surface": "#FFFFFF",
"--ui-input": "#F7F8FA",
"--ui-table-header": "#E7E9ED",
"--ui-active": "#DEE6F5",
"--ui-hover": "#E8ECF3",
"--ui-tooltip": "#35383D",
"--ui-border": "#D0D4DB",
"--ui-text": "#181A1F",
"--ui-text-secondary": "#343842",
"--ui-text-muted": "#667085",
"--ui-primary": "#A9C2F3",
"--ui-primary-hover": "#8AA5DC",
"--ui-accent": "#256FDB",
"--ui-warning": "#E8B85D",
"--ui-danger": "#D94747",
"--ui-modal-secondary": "#E7E9ED",
"--ui-overlay": "rgba(0, 0, 0, 0.45)"
}
} as const;
function relativeLuminance(color: string): number {
const channels = color.slice(1).match(/.{2}/g)?.map((value) => Number.parseInt(value, 16) / 255) ?? [];
const [red, green, blue] = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
}
function contrastRatio(first: string, second: string): number {
const lighter = Math.max(relativeLuminance(first), relativeLuminance(second));
const darker = Math.min(relativeLuminance(first), relativeLuminance(second));
return (lighter + 0.05) / (darker + 0.05);
}
describe("semantic themes", () => {
it("exposes the exact frozen semantic roles for dark and light", () => {
const dark = getThemeVariables("dark");
const light = getThemeVariables("light");
expect(dark).toEqual(expectedThemes.dark);
expect(light).toEqual(expectedThemes.light);
expect(Object.keys(dark)).toEqual(Object.keys(light));
expect(Object.keys(dark)).toHaveLength(18);
expect(Object.isFrozen(dark)).toBe(true);
expect(Object.isFrozen(light)).toBe(true);
});
it("uses a focus ring role with at least 3 to 1 contrast on control surfaces", () => {
for (const theme of ["dark", "light"] as const) {
const variables = getThemeVariables(theme);
const focus = variables[UI_FOCUS_RING_VARIABLE];
for (const surface of ["--ui-canvas", "--ui-surface", "--ui-input", "--ui-active", "--ui-hover"] as const) {
expect(contrastRatio(focus, variables[surface]), `${theme} focus on ${surface}`).toBeGreaterThanOrEqual(3);
}
}
});
});
describe("new UI primitives", () => {
it("renders labelled current-color outline icons without emoji text", () => {
const html = renderToStaticMarkup(<Icon name="download" label="Downloads" />);
expect(html).toContain("aria-label=\"Downloads\"");
expect(html).toContain("<svg");
expect(html).toContain("stroke=\"currentColor\"");
expect(html).not.toMatch(/[\u{1F300}-\u{1FAFF}]/u);
});
it("gives toolbar roles and search controls accessible names", () => {
const html = renderToStaticMarkup(
<Toolbar label="Downloadaktionen">
<ToolbarGroup label="Steuerung">
<button type="button">Start</button>
</ToolbarGroup>
<ToolbarSearch label="Downloads durchsuchen" value="paket" onChange={() => {}} />
</Toolbar>
);
expect(html).toContain("role=\"toolbar\"");
expect(html).toContain("aria-label=\"Downloadaktionen\"");
expect(html).toContain("role=\"group\"");
expect(html).toContain("aria-label=\"Steuerung\"");
expect(html).toContain("type=\"search\"");
expect(html).toContain("aria-label=\"Downloads durchsuchen\"");
});
it("keeps empty content inside the aria table body", () => {
const html = renderToStaticMarkup(
<DataTable>
<DataTableHeader>Spalten</DataTableHeader>
<DataTableBody>
<DataTableEmpty title="Keine Einträge" description="Noch sind keine Daten vorhanden." />
</DataTableBody>
<DataTableFooter pageSize={10} rangeLabel="0 von 0" paginationVisible />
</DataTable>
);
expect(html).toContain("role=\"table\"");
expect(html).toContain("aria-label=\"Datentabelle\"");
expect(html.indexOf("Keine Einträge")).toBeGreaterThan(html.indexOf("data-ui-region=\"table-body\""));
expect(html).not.toContain("<table");
expect(html).toContain("10 pro Seite");
expect(html).toContain("0 von 0");
});
it("omits the entire footer when pagination is not visible", () => {
const html = renderToStaticMarkup(
<DataTableFooter pageSize={25} rangeLabel="125 von 80" paginationVisible={false} />
);
expect(html).toBe("");
});
it.each([
["null", null],
["false", false],
["whitespace", " \n\t"],
["empty array", []],
["nested empty array", [null, false, " ", []]]
])("omits context help for %s content", (_label, content) => {
const html = renderToStaticMarkup(
<ContextInfoButton
contextName="Downloads"
content={content}
open={false}
onOpenChange={() => {}}
/>
);
expect(html).toBe("");
});
it("renders an accessible trigger and named region for open real help", () => {
const html = renderToStaticMarkup(
<ContextInfoButton
contextName="Downloads"
content={["Vorhandene ", <strong key="help">Download-Hilfe</strong>]}
open
onOpenChange={() => {}}
/>
);
expect(html).toContain("aria-label=\"Informationen\"");
expect(html).toContain("aria-expanded=\"true\"");
expect(html).toContain("role=\"region\"");
expect(html).toContain("aria-label=\"Informationen zu Downloads\"");
expect(html).toContain("Vorhandene <strong>Download-Hilfe</strong>");
});
});
+182
View File
@@ -0,0 +1,182 @@
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { runLatestUpdateCheck, shouldApplyUpdateCheckResult } from "../src/renderer/App";
import type { UpdateCheckResult } from "../src/shared/types";
import { AppHeader } from "../src/renderer/shell/AppHeader";
import { getUpdateDialogFocusTarget, UpdateExperience } from "../src/renderer/shell/UpdateExperience";
const callbacks = {
onOpen: () => {},
onClose: () => {},
onInstall: () => {},
onLater: () => {}
};
describe("update experience", () => {
it("renders the available update and prompt as one accessible experience", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={0}
releaseNotes="Changes"
state="prompt"
{...callbacks}
/>
);
expect(html).toContain("aria-label=\"Update verfügbar\"");
expect(html).toContain("role=\"tooltip\"");
expect(html).toContain("Eine neue Version ist bereit. Klicke hier, um sie zu installieren.");
expect(html).toContain("role=\"dialog\"");
expect(html).toContain("aria-modal=\"true\"");
expect(html).toContain("Update installieren");
expect(html).toContain("Jetzt aktualisieren");
expect(html).toContain("Später");
expect(html).toContain("Changes");
expect(html).toContain("<details");
});
it("keeps the update affordance but removes the dialog when the prompt is closed", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open={false}
progress={0}
releaseNotes="Changes"
state="prompt"
{...callbacks}
/>
);
expect(html).toContain("aria-label=\"Update verfügbar\"");
expect(html).not.toContain("role=\"dialog\"");
});
it("renders active progress without controls that could close the installation", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={{ percent: 47, text: "Update-Download: 47% (47 MB / 100 MB)" }}
releaseNotes=""
state="downloading"
{...callbacks}
/>
);
expect(html).toContain("Update-Download: 47% (47 MB / 100 MB)");
expect(html).toContain("aria-valuenow=\"47\"");
expect(html).not.toContain("Später");
expect(html).not.toContain("Jetzt aktualisieren");
expect(html).not.toContain("aria-label=\"Schließen\"");
});
it("preserves the original installation error in the reusable dialog", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open
progress={{ percent: null, text: "Update-Fehler: Originale Prüfsummenmeldung" }}
releaseNotes=""
state="error"
{...callbacks}
/>
);
expect(html).toContain("Update-Fehler: Originale Prüfsummenmeldung");
expect(html).toContain("aria-label=\"Schließen\"");
});
it("renders nothing when no update is available and no dialog is active", () => {
const html = renderToStaticMarkup(
<UpdateExperience
available={false}
currentVersion="v2.0.12"
latestTag=""
open={false}
progress={0}
releaseNotes=""
state="prompt"
{...callbacks}
/>
);
expect(html).toBe("");
});
it("places the update affordance in the accessible global header action group", () => {
const html = renderToStaticMarkup(
<AppHeader
activeView="downloads"
actions={(
<UpdateExperience
available
currentVersion="v2.0.12"
latestTag="v9.9.9"
open={false}
progress={0}
releaseNotes=""
state="prompt"
{...callbacks}
/>
)}
onViewChange={() => {}}
/>
);
expect(html).toContain("role=\"group\"");
expect(html).toContain("aria-label=\"Globale Aktionen\"");
expect(html).toContain("aria-label=\"Update verfügbar\"");
});
it("uses the specified transient and modal elevation tokens", () => {
const css = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
expect(css).toMatch(/\.md-update-tooltip\s*\{[^}]*box-shadow:\s*0 4px 12px rgb\(0 0 0 \/ 35%\)/s);
expect(css).toMatch(/\.md-update-dialog\s*\{[^}]*box-shadow:\s*0 12px 40px rgb\(0 0 0 \/ 45%\)/s);
});
it("keeps forward and reverse tabbing inside the update dialog", () => {
expect(getUpdateDialogFocusTarget(false, -1, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, -1, 4)).toBe(3);
expect(getUpdateDialogFocusTarget(false, 3, 4)).toBe(0);
expect(getUpdateDialogFocusTarget(true, 0, 4)).toBe(3);
expect(getUpdateDialogFocusTarget(false, 1, 4)).toBeNull();
expect(getUpdateDialogFocusTarget(false, -1, 0)).toBeNull();
});
it("rejects stale update-check completions without discarding the latest state", () => {
expect(shouldApplyUpdateCheckResult(4, 4)).toBe(true);
expect(shouldApplyUpdateCheckResult(3, 4)).toBe(false);
expect(shouldApplyUpdateCheckResult(4, 5)).toBe(false);
});
it("applies only the latest result when update checks complete out of order", async () => {
const generation = { current: 0 };
const applied: string[] = [];
let finishStartup: ((result: UpdateCheckResult) => void) | undefined;
let finishManual: ((result: UpdateCheckResult) => void) | undefined;
const startup = new Promise<UpdateCheckResult>((resolve) => { finishStartup = resolve; });
const manual = new Promise<UpdateCheckResult>((resolve) => { finishManual = resolve; });
const apply = (result: UpdateCheckResult): void => { applied.push(result.latestTag); };
const startupRun = runLatestUpdateCheck(generation, () => startup, apply);
const manualRun = runLatestUpdateCheck(generation, () => manual, apply);
finishManual?.({ updateAvailable: true, currentVersion: "2.0.12", latestVersion: "9.9.9", latestTag: "v9.9.9", releaseUrl: "https://example.test/v9.9.9" });
await manualRun;
finishStartup?.({ updateAvailable: false, currentVersion: "2.0.12", latestVersion: "2.0.12", latestTag: "v2.0.12", releaseUrl: "https://example.test/v2.0.12" });
await startupRun;
expect(applied).toEqual(["v9.9.9"]);
});
});
+271
View File
@@ -0,0 +1,271 @@
import React, { type ReactElement } from "react";
import { describe, expect, it } from "vitest";
import { App } from "../src/renderer/App";
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts";
import type { ElectronApi } from "../src/shared/preload-api";
import * as visualFixtures from "./visual/fixtures";
import * as visualMain from "./visual/main";
import { createVisualElectronApi } from "./visual/mock-electron-api";
const { createVisualFixture } = visualFixtures;
interface TestVisualRoot {
innerText: string;
textContent: string | null;
dataset: {
visualError?: string;
};
}
interface TestVisualMarker {
visualReady?: string;
visualScenario?: string;
}
function createTestVisualBootstrap(
search: string,
initialInnerText: string,
onFrame: (frame: number, rootElement: TestVisualRoot) => void,
maxFrames = 2
) {
const rootElement: TestVisualRoot = {
innerText: initialInnerText,
textContent: initialInnerText,
dataset: {}
};
const marker: TestVisualMarker = {};
const createdRootElements: TestVisualRoot[] = [];
const renderedElements: ReactElement[] = [];
const assignedApis: ElectronApi[] = [];
const markerValuesByFrame: Array<string | undefined> = [];
const clockInstalls: string[] = [];
let frameCount = 0;
const runtime = {
search,
rootElement,
marker,
maxFrames,
installClock(): void {
clockInstalls.push(search);
},
setElectronApi(api: ElectronApi): void {
assignedApis.push(api);
},
createRoot(element: TestVisualRoot) {
createdRootElements.push(element);
return {
render(renderedElement: ReactElement): void {
renderedElements.push(renderedElement);
}
};
},
requestFrame(callback: FrameRequestCallback): number {
frameCount += 1;
markerValuesByFrame.push(marker.visualReady);
onFrame(frameCount, rootElement);
callback(0);
return frameCount;
}
};
return {
runtime,
rootElement,
marker,
createdRootElements,
renderedElements,
assignedApis,
markerValuesByFrame,
clockInstalls
};
}
describe("visual fixtures", () => {
it("keeps empty, dense and update states deterministic and distinct", () => {
const empty = createVisualFixture("empty");
const dense = createVisualFixture("dense");
const update = createVisualFixture("update");
expect(Object.keys(empty.snapshot.session.packages)).toHaveLength(0);
expect(Object.keys(dense.snapshot.session.packages).length).toBeGreaterThan(1);
expect(update.update.latestTag).toBe("v9.9.9");
expect(createVisualFixture("dense")).toEqual(dense);
});
it("freezes runtime and recurring chart timers across visual frames", async () => {
const dense = createVisualFixture("dense");
const originalDateNow = Date.now;
let timerTicks = 0;
const timerTarget = {
setInterval(handler: TimerHandler, _timeout?: number): number {
if (typeof handler === "function") {
handler();
}
return 1;
}
};
const restore = visualFixtures.installVisualClock(timerTarget);
try {
const runtime = (): number => dense.snapshot.stats.sessionRuntimeMs
+ Math.max(0, Date.now() - dense.snapshot.stats.runtimeMeasuredAt);
const firstRuntime = runtime();
const frameTimes: number[] = [];
timerTarget.setInterval(() => { timerTicks += 1; }, 250);
await visualFixtures.waitForVisualFrames((callback) => {
frameTimes.push(Date.now());
callback(0);
return frameTimes.length;
});
timerTarget.setInterval(() => { timerTicks += 1; }, 250);
timerTarget.setInterval(() => { timerTicks += 1; }, 1000);
expect(Date.now()).toBe(1786312800000);
expect(firstRuntime).toBe(3600000);
expect(runtime()).toBe(firstRuntime);
expect(frameTimes).toEqual([1786312800000, 1786312800000]);
expect(timerTicks).toBe(0);
} finally {
restore();
}
expect(Date.now).toBe(originalDateNow);
});
it("aligns dense account table values with credential-derived account IDs", async () => {
const dense = createVisualFixture("dense");
const settings = dense.snapshot.settings;
const megaAccountId = getMegaDebridAccountId(settings.megaLogin);
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys);
expect(megaAccountId).toBe("mda_2f92guyzhdf6j");
expect(debridLinkKeys.map((entry) => entry.id)).toEqual([
"dlk_1ix5qlyx6mtm1",
"dlk_1ix5pfvlg4nkg"
]);
expect(settings.debridAccountStatuses[megaAccountId]?.valid).toBe(true);
expect(settings.megaDebridAccountDailyLimitBytes[megaAccountId]).toBeGreaterThan(0);
expect(settings.megaDebridAccountDailyUsageBytes[megaAccountId]).toBeGreaterThan(0);
expect(settings.megaDebridAccountTotalUsageBytes[megaAccountId]).toBeGreaterThan(
settings.megaDebridAccountDailyUsageBytes[megaAccountId]
);
for (const entry of debridLinkKeys) {
expect(settings.debridAccountStatuses[entry.id]?.valid).toBe(true);
expect(settings.debridLinkApiKeyDailyLimitBytes[entry.id]).toBeGreaterThan(0);
expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeGreaterThan(0);
expect(settings.debridLinkApiKeyDailyUsageBytes[entry.id]).toBeLessThan(
settings.debridLinkApiKeyDailyLimitBytes[entry.id]
);
expect(settings.debridLinkApiKeyTotalUsageBytes[entry.id]).toBeGreaterThan(
settings.debridLinkApiKeyDailyUsageBytes[entry.id]
);
}
const debridLinkItem = Object.values(dense.snapshot.session.items).find(
(item) => item.provider === "debridlink"
);
expect(debridLinkItem?.providerAccountId).toBe(debridLinkKeys[0].id);
const hostLimits = await createVisualElectronApi(dense).getDebridLinkHostLimits();
expect(hostLimits[0]?.keyId).toBe(debridLinkKeys[0].id);
});
it("stores every mutable bridge state inside the visual fixture", async () => {
const dense = createVisualFixture("dense");
const api = createVisualElectronApi(dense);
await api.setTraceEnabled(true);
expect(dense).toHaveProperty("traceConfig.enabled", true);
await api.enableRemoteDiagnostics({
hostMode: "network",
publicHost: "capture.example.test",
port: 8123,
allowlist: ["192.0.2.10"],
name: "Capture Harness"
});
expect(dense).toHaveProperty("remoteDiagnostics.status.running", true);
expect(dense).toHaveProperty("remoteDiagnostics.status.port", 8123);
expect(dense).toHaveProperty("remoteDiagnostics.publicHost", "capture.example.test");
await api.disableRemoteDiagnostics();
expect(dense).toHaveProperty("remoteDiagnostics.status.running", false);
});
it("boots the dense query once and waits for both visible package names", async () => {
expect(typeof window).toBe("undefined");
const harness = createTestVisualBootstrap(
"?scenario=dense",
"Dokumentation Staffel 1",
(frame, rootElement) => {
if (frame === 3) {
rootElement.innerText = "Dokumentation Staffel 1 Konzertmitschnitt 2026";
}
}
);
await visualMain.startVisualHarness(harness.runtime);
expect(harness.clockInstalls).toHaveLength(1);
expect(harness.createdRootElements).toEqual([harness.rootElement]);
expect(harness.renderedElements).toHaveLength(1);
expect(harness.renderedElements[0].type).toBe(App);
expect(harness.renderedElements[0].type).not.toBe(React.StrictMode);
expect(harness.assignedApis).toHaveLength(1);
const snapshot = await harness.assignedApis[0].getSnapshot();
expect(Object.values(snapshot.session.packages).map((pkg) => pkg.name)).toEqual([
"Dokumentation Staffel 1",
"Konzertmitschnitt 2026",
"Archiv mit Wiederholung"
]);
expect(harness.marker.visualScenario).toBe("dense");
expect(harness.markerValuesByFrame).toEqual([undefined, undefined, undefined]);
expect(harness.marker.visualReady).toBe("true");
expect(harness.rootElement.dataset.visualError).toBeUndefined();
expect(typeof window).toBe("undefined");
});
it("resolves the update query and waits for visible v9.9.9 evidence", async () => {
const harness = createTestVisualBootstrap(
"?scenario=update",
"Update verfügbar",
(frame, rootElement) => {
if (frame === 3) {
rootElement.innerText = "Update verfügbar v9.9.9";
}
}
);
await visualMain.startVisualHarness(harness.runtime);
expect(harness.createdRootElements).toHaveLength(1);
expect(harness.renderedElements).toHaveLength(1);
expect(harness.marker.visualScenario).toBe("update");
expect(harness.markerValuesByFrame).toEqual([undefined, undefined, undefined]);
expect(harness.marker.visualReady).toBe("true");
expect((await harness.assignedApis[0].checkUpdates()).latestTag).toBe("v9.9.9");
});
it("catches missing update evidence inside the bootstrap and exposes a local error", async () => {
const harness = createTestVisualBootstrap(
"?scenario=update",
"Update verfügbar",
() => undefined,
0
);
harness.marker.visualReady = "true";
await expect(visualMain.startVisualHarness(harness.runtime)).resolves.toBeUndefined();
expect(harness.createdRootElements).toHaveLength(1);
expect(harness.renderedElements).toHaveLength(1);
expect(harness.marker.visualScenario).toBe("update");
expect(harness.markerValuesByFrame).toEqual([undefined, undefined]);
expect(harness.marker.visualReady).toBeUndefined();
expect(harness.rootElement.dataset.visualError).toBe("true");
expect(harness.rootElement.textContent).toBe(
'Visual-Harness-Fehler: Visual-Harness-Szenario "update" ist nicht bereit: v9.9.9 fehlt'
);
});
});
+305
View File
@@ -0,0 +1,305 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { VISUAL_SCENARIOS } from "./visual/fixtures";
import {
prepareVisualCapture,
validateVisualCaptureManifest,
type VisualCapture
} from "./visual/ui-driver";
const validCapture: VisualCapture = {
name: "region-contract",
scenario: "dense",
viewport: { width: 2560, height: 1369 },
activeView: "downloads",
interactions: [],
assertions: [{ type: "visible", region: "downloads-table-body" }]
};
interface FakeElementOptions {
role?: string;
name?: string;
region?: string;
className?: string;
visible?: boolean;
current?: boolean;
}
class FakeElement {
readonly nodeType = 1;
readonly tagName = "DIV";
readonly dataset: Record<string, string> = {};
readonly style = { display: "", visibility: "", opacity: "", zIndex: "auto" };
readonly className: string;
readonly role?: string;
readonly name?: string;
readonly region?: string;
readonly visible: boolean;
readonly current: boolean;
textContent = "content";
hidden = false;
readonly classList: { contains: (name: string) => boolean };
constructor(options: FakeElementOptions) {
this.role = options.role;
this.name = options.name;
this.region = options.region;
this.className = options.className ?? "";
this.visible = options.visible ?? true;
this.current = options.current ?? false;
this.classList = {
contains: (name: string): boolean => this.className.split(/\s+/).includes(name)
};
if (this.region) {
this.dataset.visualRegion = this.region;
}
}
getAttribute(name: string): string | null {
if (name === "role") return this.role ?? null;
if (name === "aria-label") return this.name ?? null;
if (name === "data-visual-region") return this.region ?? null;
if (name === "aria-hidden") return this.visible ? null : "true";
if (name === "aria-current" && this.current) return "page";
return null;
}
hasAttribute(name: string): boolean {
return name === "hidden" ? this.hidden : this.getAttribute(name) !== null;
}
getClientRects(): { length: number } {
return { length: this.visible ? 1 : 0 };
}
querySelectorAll(): FakeElement[] {
return [];
}
}
function createFakeDocument(
elements: FakeElement[],
navigationName = "Downloads"
): Document {
const navigation = new FakeElement({
role: "button",
name: navigationName,
className: "tab",
current: true
});
Object.assign(navigation, {
tagName: "BUTTON",
click(): void {
return undefined;
},
focus(): void {
return undefined;
},
dispatchEvent(): boolean {
return true;
}
});
const all = [navigation, ...elements];
class FakeEvent {
constructor(readonly type: string) {}
}
return {
defaultView: {
Event: FakeEvent,
MouseEvent: FakeEvent,
KeyboardEvent: FakeEvent,
requestAnimationFrame(callback: FrameRequestCallback): number {
callback(0);
return 1;
},
getComputedStyle(element: FakeElement) {
return {
display: element.visible ? "block" : "none",
visibility: element.visible ? "visible" : "hidden",
opacity: element.visible ? "1" : "0",
zIndex: element.style.zIndex
};
}
},
querySelectorAll(selector: string): FakeElement[] {
if (selector === "[data-visual-region]") {
return all.filter((element) => element.region !== undefined);
}
return all;
},
querySelector(): FakeElement | null {
return null;
}
} as unknown as Document;
}
describe("reference capture manifest", () => {
const manifest = JSON.parse(readFileSync(new URL("./visual/capture-manifest.json", import.meta.url), "utf8"));
it("defines executable dense, collector, avatar, context and info captures", () => {
expect(VISUAL_SCENARIOS).toEqual(["empty", "dense", "update"]);
expect(validateVisualCaptureManifest(manifest)).toEqual([]);
expect(manifest.map((entry: { name: string }) => entry.name)).toEqual(expect.arrayContaining([
"downloads-dense",
"app-navigation-current",
"collector-dense",
"settings-dense",
"history-dense",
"statistics-dense",
"avatar-menu",
"avatar-update-tooltip",
"context-downloads",
"context-collector",
"context-settings",
"context-history",
"context-statistics",
"info-closed",
"info-open",
"info-absent"
]));
const collector = manifest.find((entry: { name: string }) => entry.name === "collector-dense");
expect(collector.interactions).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "fill", role: "textbox", name: "Links" })
]));
expect(collector.assertions).toContainEqual(expect.objectContaining({
type: "minimum-row-count",
region: "collector-table-body",
value: 2
}));
const avatarUpdate = manifest.find((entry: { name: string }) => entry.name === "avatar-update-tooltip");
expect(avatarUpdate.assertions).toContainEqual({
type: "visible",
role: "button",
name: "Update verfügbar"
});
});
it("defines representative responsive captures before the final matrix", () => {
expect(manifest).toEqual(expect.arrayContaining([
expect.objectContaining({
name: "responsive-downloads-1920",
viewport: { width: 1920, height: 1080 }
}),
expect.objectContaining({
name: "responsive-collector-1366",
viewport: { width: 1366, height: 768 }
}),
expect.objectContaining({
name: "responsive-settings-1120",
viewport: { width: 1120, height: 760 }
})
]));
const minimumSettings = manifest.find((entry: { name: string }) => entry.name === "responsive-settings-1120");
expect(minimumSettings.interactions.slice(0, 2)).toEqual([
{ type: "click", role: "button", name: "Seitenleiste ausklappen" },
{ type: "click", role: "button", name: "Accounts" }
]);
});
it("covers every primary view at every supported viewport exactly once", () => {
const primaryViews = ["downloads", "collector", "settings", "history", "statistics"];
const supportedViewports = [
{ width: 2560, height: 1369 },
{ width: 1920, height: 1080 },
{ width: 1366, height: 768 },
{ width: 1120, height: 760 }
];
const expectedCells = primaryViews.flatMap((activeView) =>
supportedViewports.map((viewport) => `${activeView}@${viewport.width}x${viewport.height}`)
);
const matrixEntries = manifest.filter((entry: {
name: string;
activeView: string;
viewport: { width: number; height: number };
}) => primaryViews.includes(entry.activeView) && supportedViewports.some((viewport) =>
viewport.width === entry.viewport.width && viewport.height === entry.viewport.height
) && (
entry.name.endsWith("-dense") ||
entry.name.startsWith("responsive-")
));
const actualCells = matrixEntries.map((entry: {
activeView: string;
viewport: { width: number; height: number };
}) => `${entry.activeView}@${entry.viewport.width}x${entry.viewport.height}`);
expect(matrixEntries).toHaveLength(20);
expect(new Set(matrixEntries.map((entry: { name: string }) => entry.name)).size).toBe(20);
expect([...actualCells].sort()).toEqual([...expectedCells].sort());
});
it("reports every invalid required field with its manifest path", () => {
expect(validateVisualCaptureManifest([{}])).toEqual([
"$[0].name must be a nonempty string",
"$[0].scenario must be one of empty, dense, update",
"$[0].viewport must be an object",
"$[0].activeView must be one of downloads, collector, settings, history, statistics",
"$[0].interactions must be an array",
"$[0].assertions must be an array"
]);
});
it("rejects region values outside the exact marker-name pattern", () => {
expect(validateVisualCaptureManifest([{
...validCapture,
assertions: [{ type: "visible", region: "Downloads_Table" }]
}])).toContain('$[0].assertions[0].region must match ^[a-z0-9]+(?:-[a-z0-9]+)*$');
});
it("does not use a same-named CSS class as a region fallback", async () => {
const document = createFakeDocument([
new FakeElement({ className: "downloads-table-body" })
]);
await expect(prepareVisualCapture(validCapture, document)).rejects.toThrow(
'region marker "downloads-table-body" is missing'
);
});
it("resolves one visible exact region marker", async () => {
const document = createFakeDocument([
new FakeElement({ region: "downloads-table-body" }),
new FakeElement({ region: "downloads-table" })
]);
await expect(prepareVisualCapture(validCapture, document)).resolves.toBeUndefined();
});
it("rejects duplicate visible exact region markers as ambiguous", async () => {
const document = createFakeDocument([
new FakeElement({ region: "downloads-table-body" }),
new FakeElement({ region: "downloads-table-body" })
]);
await expect(prepareVisualCapture(validCapture, document)).rejects.toThrow(
'region marker "downloads-table-body" is ambiguous'
);
});
it("rejects duplicate region markers before evaluating absence", async () => {
const document = createFakeDocument([
new FakeElement({ region: "downloads-table-body" }),
new FakeElement({ region: "downloads-table-body" })
]);
const capture: VisualCapture = {
...validCapture,
assertions: [{ type: "absent", region: "downloads-table-body" }]
};
await expect(prepareVisualCapture(capture, document)).rejects.toThrow(
'region marker "downloads-table-body" is ambiguous'
);
});
it("selects the view tab when another button has the same accessible name", async () => {
const document = createFakeDocument([
new FakeElement({ role: "button", name: "Einstellungen", className: "menu-bar-trigger" })
], "Einstellungen");
const capture: VisualCapture = {
...validCapture,
activeView: "settings",
assertions: [{ type: "active-view", value: "settings" }]
};
await expect(prepareVisualCapture(capture, document)).resolves.toBeUndefined();
});
});
+414
View File
@@ -0,0 +1,414 @@
[
{
"name": "app-navigation-current",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "history",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "history" }
]
},
{
"name": "downloads-dense",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "downloads",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "downloads" },
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
{ "type": "visible", "region": "downloads-sidebar-status" },
{ "type": "visible", "region": "downloads-pagination" }
]
},
{
"name": "collector-dense",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "collector",
"interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" }
],
"assertions": [
{ "type": "active-view", "value": "collector" },
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
{ "type": "absent", "region": "collector-empty-state" }
]
},
{
"name": "settings-dense",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "settings",
"interactions": [
{ "type": "click", "role": "button", "name": "Accounts" }
],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
{ "type": "visible", "region": "settings-sidebar" }
]
},
{
"name": "history-dense",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "history",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "history" },
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
{ "type": "visible", "region": "history-pagination" }
]
},
{
"name": "statistics-dense",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "statistics",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "statistics" },
{ "type": "nonempty", "region": "statistics-kpis" },
{ "type": "visible", "region": "statistics-chart" }
]
},
{
"name": "avatar-menu",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "settings",
"interactions": [
{ "type": "click", "role": "button", "name": "Kontomenü" }
],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "visible", "role": "menu", "name": "Kontomenü" },
{ "type": "nonempty", "role": "menu", "name": "Kontomenü" }
]
},
{
"name": "avatar-update-tooltip",
"scenario": "update",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "settings",
"interactions": [
{ "type": "click", "role": "button", "name": "Später" },
{ "type": "wait-absent", "role": "dialog", "name": "Update installieren" },
{ "type": "click", "role": "button", "name": "Kontomenü" },
{ "type": "hover", "role": "button", "name": "Update verfügbar" }
],
"assertions": [
{ "type": "absent", "role": "dialog", "name": "Update installieren" },
{ "type": "visible", "role": "button", "name": "Update verfügbar" },
{ "type": "visible", "role": "menu", "name": "Kontomenü" },
{ "type": "visible", "role": "tooltip", "name": "Update verfügbar" },
{ "type": "layer-above", "role": "tooltip", "name": "Update verfügbar", "referenceRole": "menu", "referenceName": "Kontomenü" }
]
},
{
"name": "context-downloads",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "downloads",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "downloads" },
{ "type": "visible", "region": "downloads-sidebar" },
{ "type": "visible", "region": "downloads-toolbar" },
{ "type": "visible", "region": "downloads-pagination" }
]
},
{
"name": "context-collector",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "collector",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "collector" },
{ "type": "visible", "region": "collector-sidebar" },
{ "type": "visible", "region": "collector-toolbar" },
{ "type": "absent", "region": "downloads-toolbar" }
]
},
{
"name": "context-settings",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "settings",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "visible", "region": "settings-sidebar" },
{ "type": "absent", "region": "table-pagination" }
]
},
{
"name": "context-history",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "history",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "history" },
{ "type": "visible", "region": "history-sidebar" },
{ "type": "visible", "region": "history-toolbar" },
{ "type": "visible", "region": "history-pagination" }
]
},
{
"name": "context-statistics",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "statistics",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "statistics" },
{ "type": "visible", "region": "statistics-sidebar" },
{ "type": "absent", "region": "downloads-toolbar" }
]
},
{
"name": "info-closed",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "downloads",
"interactions": [],
"assertions": [
{ "type": "visible", "role": "button", "name": "Informationen" },
{ "type": "absent", "role": "region", "name": "Informationen zu Downloads" }
]
},
{
"name": "info-open",
"scenario": "dense",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "downloads",
"interactions": [
{ "type": "click", "role": "button", "name": "Informationen" }
],
"assertions": [
{ "type": "visible", "role": "region", "name": "Informationen zu Downloads" },
{ "type": "nonempty", "role": "region", "name": "Informationen zu Downloads" }
]
},
{
"name": "info-absent",
"scenario": "empty",
"viewport": { "width": 2560, "height": 1369 },
"activeView": "settings",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "absent", "role": "button", "name": "Informationen" }
]
},
{
"name": "responsive-downloads-1920",
"scenario": "dense",
"viewport": { "width": 1920, "height": 1080 },
"activeView": "downloads",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "downloads" },
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
{ "type": "visible", "region": "downloads-sidebar-status" },
{ "type": "visible", "region": "downloads-pagination" }
]
},
{
"name": "responsive-collector-1366",
"scenario": "dense",
"viewport": { "width": 1366, "height": 768 },
"activeView": "collector",
"interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" }
],
"assertions": [
{ "type": "active-view", "value": "collector" },
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
{ "type": "absent", "region": "collector-empty-state" }
]
},
{
"name": "responsive-settings-1120",
"scenario": "dense",
"viewport": { "width": 1120, "height": 760 },
"activeView": "settings",
"interactions": [
{ "type": "click", "role": "button", "name": "Seitenleiste ausklappen" },
{ "type": "click", "role": "button", "name": "Accounts" }
],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
{ "type": "visible", "region": "settings-sidebar" },
{ "type": "absent", "region": "table-pagination" }
]
},
{
"name": "responsive-downloads-1366",
"scenario": "dense",
"viewport": { "width": 1366, "height": 768 },
"activeView": "downloads",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "downloads" },
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
{ "type": "visible", "region": "downloads-pagination" }
]
},
{
"name": "responsive-downloads-1120",
"scenario": "dense",
"viewport": { "width": 1120, "height": 760 },
"activeView": "downloads",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "downloads" },
{ "type": "minimum-row-count", "region": "downloads-table-body", "value": 1 },
{ "type": "visible", "region": "downloads-pagination" }
]
},
{
"name": "responsive-collector-1920",
"scenario": "dense",
"viewport": { "width": 1920, "height": 1080 },
"activeView": "collector",
"interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" }
],
"assertions": [
{ "type": "active-view", "value": "collector" },
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
{ "type": "absent", "region": "collector-empty-state" }
]
},
{
"name": "responsive-collector-1120",
"scenario": "dense",
"viewport": { "width": 1120, "height": 760 },
"activeView": "collector",
"interactions": [
{ "type": "click", "role": "button", "name": "Links hinzufügen" },
{ "type": "fill", "role": "textbox", "name": "Links", "value": "https://example.test/a\nhttps://example.test/b" },
{ "type": "click", "role": "button", "name": "Übernehmen" }
],
"assertions": [
{ "type": "active-view", "value": "collector" },
{ "type": "minimum-row-count", "region": "collector-table-body", "value": 2 },
{ "type": "absent", "region": "collector-empty-state" }
]
},
{
"name": "responsive-settings-1920",
"scenario": "dense",
"viewport": { "width": 1920, "height": 1080 },
"activeView": "settings",
"interactions": [
{ "type": "click", "role": "button", "name": "Accounts" }
],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
{ "type": "visible", "region": "settings-sidebar" },
{ "type": "absent", "region": "table-pagination" }
]
},
{
"name": "responsive-settings-1366",
"scenario": "dense",
"viewport": { "width": 1366, "height": 768 },
"activeView": "settings",
"interactions": [
{ "type": "click", "role": "button", "name": "Seitenleiste ausklappen" },
{ "type": "click", "role": "button", "name": "Accounts" }
],
"assertions": [
{ "type": "active-view", "value": "settings" },
{ "type": "minimum-row-count", "region": "accounts-table-body", "value": 1 },
{ "type": "visible", "region": "settings-sidebar" },
{ "type": "absent", "region": "table-pagination" }
]
},
{
"name": "responsive-history-1920",
"scenario": "dense",
"viewport": { "width": 1920, "height": 1080 },
"activeView": "history",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "history" },
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
{ "type": "visible", "region": "history-pagination" }
]
},
{
"name": "responsive-history-1366",
"scenario": "dense",
"viewport": { "width": 1366, "height": 768 },
"activeView": "history",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "history" },
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
{ "type": "visible", "region": "history-pagination" }
]
},
{
"name": "responsive-history-1120",
"scenario": "dense",
"viewport": { "width": 1120, "height": 760 },
"activeView": "history",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "history" },
{ "type": "minimum-row-count", "region": "history-table-body", "value": 1 },
{ "type": "visible", "region": "history-pagination" }
]
},
{
"name": "responsive-statistics-1920",
"scenario": "dense",
"viewport": { "width": 1920, "height": 1080 },
"activeView": "statistics",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "statistics" },
{ "type": "nonempty", "region": "statistics-kpis" },
{ "type": "visible", "region": "statistics-chart" }
]
},
{
"name": "responsive-statistics-1366",
"scenario": "dense",
"viewport": { "width": 1366, "height": 768 },
"activeView": "statistics",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "statistics" },
{ "type": "nonempty", "region": "statistics-kpis" },
{ "type": "visible", "region": "statistics-chart" }
]
},
{
"name": "responsive-statistics-1120",
"scenario": "dense",
"viewport": { "width": 1120, "height": 760 },
"activeView": "statistics",
"interactions": [],
"assertions": [
{ "type": "active-view", "value": "statistics" },
{ "type": "nonempty", "region": "statistics-kpis" },
{ "type": "visible", "region": "statistics-chart" }
]
}
]
+59
View File
@@ -0,0 +1,59 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Visual Driver Test</title>
<style>
* {
transition: none !important;
}
body {
font-family: sans-serif;
margin: 24px;
}
nav,
main,
[role="dialog"],
[role="menu"],
[role="tooltip"] {
display: flex;
gap: 12px;
padding: 12px;
}
main,
[role="dialog"],
[role="menu"],
[role="tooltip"] {
flex-direction: column;
}
[role="dialog"] {
position: fixed;
inset: 80px;
z-index: 30;
background: white;
border: 1px solid black;
}
[role="menu"] {
position: fixed;
top: 80px;
right: 24px;
z-index: 40;
background: white;
border: 1px solid black;
}
[role="tooltip"] {
position: fixed;
top: 140px;
right: 24px;
z-index: 50;
background: black;
color: white;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/driver-test.tsx"></script>
</body>
</html>
+156
View File
@@ -0,0 +1,156 @@
import { useState } from "react";
import { createRoot } from "react-dom/client";
import {
loadVisualCapture,
prepareVisualCapture,
type MainViewId
} from "./ui-driver";
const views: Array<{ id: MainViewId; name: string }> = [
{ id: "downloads", name: "Downloads" },
{ id: "collector", name: "Linksammler" },
{ id: "settings", name: "Einstellungen" },
{ id: "history", name: "Verlauf" },
{ id: "statistics", name: "Statistiken" }
];
function DriverTestApp({ updateAvailable }: { updateAvailable: boolean }) {
const [activeView, setActiveView] = useState<MainViewId>("downloads");
const [collectorOpen, setCollectorOpen] = useState(false);
const [collectorValue, setCollectorValue] = useState("");
const [collectorRows, setCollectorRows] = useState<string[]>([]);
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [updateDialogOpen, setUpdateDialogOpen] = useState(updateAvailable);
const [updateTooltipOpen, setUpdateTooltipOpen] = useState(false);
const [infoOpen, setInfoOpen] = useState(false);
return (
<>
<nav aria-label="Hauptnavigation">
{views.map((view) => (
<button
key={view.id}
type="button"
aria-current={activeView === view.id ? "page" : undefined}
onClick={() => setActiveView(view.id)}
>
{view.name}
</button>
))}
</nav>
<main data-visual-active-view={activeView}>
{activeView === "downloads" && (
<>
<div data-visual-region="downloads-sidebar">Download-Seitenleiste</div>
<div data-visual-region="downloads-sidebar-status">3 Pakete aktiv</div>
<div data-visual-region="downloads-toolbar">Download-Werkzeuge</div>
<div data-visual-region="downloads-table-body">
<div role="row">Dokumentation Staffel 1</div>
</div>
<div data-visual-region="downloads-pagination">Seite 1 von 1</div>
<button type="button" onClick={() => setInfoOpen((open) => !open)}>Informationen</button>
{infoOpen && <section aria-label="Informationen zu Downloads">Drei Downloads sind sichtbar.</section>}
</>
)}
{activeView === "collector" && (
<>
<div data-visual-region="collector-sidebar">Linksammler-Seitenleiste</div>
<div data-visual-region="collector-toolbar">Linksammler-Werkzeuge</div>
<button type="button" onClick={() => setCollectorOpen(true)}>Links hinzufügen</button>
{collectorRows.length === 0 && <div data-visual-region="collector-empty-state">Keine Links</div>}
<div data-visual-region="collector-table-body">
{collectorRows.map((link) => <div role="row" key={link}>{link}</div>)}
</div>
</>
)}
{activeView === "settings" && (
<>
<div data-visual-region="settings-sidebar">Einstellungs-Seitenleiste</div>
<button type="button">Accounts</button>
<div data-visual-region="accounts-table-body"><div role="row">Visual Account</div></div>
<button
type="button"
disabled={updateDialogOpen}
onClick={() => setAccountMenuOpen((open) => !open)}
>
Kontomenü
</button>
</>
)}
{activeView === "history" && (
<>
<div data-visual-region="history-sidebar">Verlauf-Seitenleiste</div>
<div data-visual-region="history-toolbar">Verlauf-Werkzeuge</div>
<div data-visual-region="history-table-body"><div role="row">Naturfilm Sammlung</div></div>
<div data-visual-region="history-pagination">Seite 1 von 1</div>
</>
)}
{activeView === "statistics" && (
<>
<div data-visual-region="statistics-sidebar">Statistik-Seitenleiste</div>
<div data-visual-region="statistics-kpis">919,82 GB</div>
<div data-visual-region="statistics-chart">Download-Verlauf</div>
</>
)}
</main>
{collectorOpen && (
<div role="dialog" aria-label="Links hinzufügen">
<textarea aria-label="Links" value={collectorValue} onChange={(event) => setCollectorValue(event.target.value)} />
<button
type="button"
onClick={() => {
setCollectorRows(collectorValue.split(/\r?\n/).map((value) => value.trim()).filter(Boolean));
setCollectorOpen(false);
}}
>
Übernehmen
</button>
</div>
)}
{updateDialogOpen && (
<div role="dialog" aria-label="Update installieren">
<strong>Update installieren</strong>
<button type="button" onClick={() => setUpdateDialogOpen(false)}>Später</button>
</div>
)}
{accountMenuOpen && (
<div role="menu" aria-label="Kontomenü">
<button
type="button"
aria-label="Update verfügbar"
onMouseEnter={() => setUpdateTooltipOpen(true)}
onFocus={() => setUpdateTooltipOpen(true)}
>
Update verfügbar
</button>
</div>
)}
{updateTooltipOpen && <div role="tooltip" aria-label="Update verfügbar">Version 9.9.9 verfügbar</div>}
</>
);
}
async function startDriverTest(): Promise<void> {
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element fehlt");
}
const captureName = new URLSearchParams(window.location.search).get("capture");
if (!captureName) {
throw new Error("Capture fehlt");
}
try {
const capture = await loadVisualCapture(captureName);
createRoot(rootElement).render(<DriverTestApp updateAvailable={capture.scenario === "update"} />);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
await prepareVisualCapture(capture, document);
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
document.documentElement.dataset.visualReady = "true";
} catch (error) {
delete document.documentElement.dataset.visualReady;
rootElement.dataset.visualError = "true";
rootElement.textContent = `Visual-Harness-Fehler: ${error instanceof Error ? error.message : String(error)}`;
}
}
void startDriverTest();
+585
View File
@@ -0,0 +1,585 @@
import type {
AppSettings,
HistoryEntry,
RemoteDiagnosticsInfo,
SupportTraceConfig,
UiSnapshot,
UpdateCheckResult
} from "../../src/shared/types";
import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys";
import { getMegaDebridAccountId } from "../../src/shared/mega-debrid-accounts";
export const VISUAL_SCENARIOS = ["empty", "dense", "update"] as const;
export type VisualScenario = (typeof VISUAL_SCENARIOS)[number];
export interface VisualFixture {
snapshot: UiSnapshot;
history: HistoryEntry[];
update: UpdateCheckResult;
traceConfig: SupportTraceConfig;
remoteDiagnostics: RemoteDiagnosticsInfo;
}
export interface VisualClockTarget {
setInterval: (handler: TimerHandler, timeout?: number, ...arguments_: unknown[]) => number;
}
export const VISUAL_NOW_MS = 1786312800000;
export function installVisualClock(target: VisualClockTarget): () => void {
const originalDateNow = Date.now;
const originalSetInterval = target.setInterval;
Date.now = () => VISUAL_NOW_MS;
target.setInterval = () => 0;
return () => {
Date.now = originalDateNow;
target.setInterval = originalSetInterval;
};
}
export async function waitForVisualFrames(
requestFrame: (callback: FrameRequestCallback) => number
): Promise<void> {
const waitForFrame = (): Promise<void> => new Promise((resolve) => {
requestFrame(() => resolve());
});
await waitForFrame();
await waitForFrame();
}
function createSettings(): AppSettings {
const megaLogin = "visual@example.test";
const debridLinkApiKeys = "visual-debrid-link-key-1\nvisual-debrid-link-key-2";
const megaAccountId = getMegaDebridAccountId(megaLogin);
const debridLinkKeys = parseDebridLinkApiKeys(debridLinkApiKeys);
return {
token: "visual-real-debrid-token",
realDebridUseWebLogin: false,
megaLogin,
megaPassword: "visual-password",
megaCredentials: `${megaLogin}:visual-password`,
megaDebridApiEnabled: true,
megaDebridWebEnabled: true,
megaDebridPreferApi: true,
bestToken: "visual-best-debrid-token",
bestDebridUseWebLogin: false,
allDebridToken: "visual-all-debrid-token",
allDebridUseWebLogin: false,
ddownloadLogin: "visual-ddownload",
ddownloadPassword: "visual-password",
oneFichierApiKey: "visual-onefichier-key",
debridLinkApiKeys,
debridLinkDisabledKeyIds: [],
linkSnappyLogin: "visual-linksnappy",
linkSnappyPassword: "visual-password",
archivePasswordList: "visual-archive-password",
rememberToken: true,
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid", "alldebrid", "debridlink"],
providerPrimary: "realdebrid",
providerSecondary: "megadebrid-api",
providerTertiary: "bestdebrid",
autoProviderFallback: true,
outputDir: "C:\\Visual\\Downloads",
packageName: "",
autoExtract: true,
autoRename4sf4sj: true,
keepGermanAudioOnly: false,
germanAudioMode: "tag",
extractDir: "C:\\Visual\\Extracted",
collectMkvToLibrary: true,
mkvLibraryDir: "C:\\Visual\\Library",
createExtractSubfolder: true,
hybridExtract: true,
cleanupMode: "none",
extractConflictMode: "overwrite",
removeLinkFilesAfterExtract: true,
removeSamplesAfterExtract: true,
enableIntegrityCheck: true,
autoResumeOnStart: true,
autoReconnect: true,
reconnectWaitSeconds: 45,
completedCleanupPolicy: "never",
maxParallel: 4,
maxParallelExtract: 2,
retryLimit: 3,
speedLimitEnabled: false,
speedLimitKbps: 0,
speedLimitMode: "global",
updateRepo: "Sucukdeluxe/multi-debrid-downloader",
autoUpdateCheck: true,
clipboardWatch: true,
minimizeToTray: false,
theme: "dark",
collapseNewPackages: false,
historyRetentionMode: "permanent",
historyMaxEntries: 500,
historyMaxAgeDays: 0,
accountListShowDetailedDebridLinkKeys: true,
autoSortPackagesByProgress: false,
autoSkipExtracted: false,
hideExtractedItems: false,
confirmDeleteSelection: true,
backupIncludeDownloads: false,
backupIncludeRemoteDiagnostics: false,
notifyUrl: "https://example.test/visual-webhook",
notifyMention: "@visual",
notifyOnPackageCompleted: true,
notifyOnPackageFailed: true,
notifyOnRunFinished: true,
totalDownloadedAllTime: 987654321000,
totalCompletedFilesAllTime: 842,
totalRuntimeAllTimeMs: 172800000,
bandwidthSchedules: [
{
id: "visual-schedule-night",
startHour: 22,
endHour: 6,
speedLimitKbps: 12288,
enabled: true
}
],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "middle",
autoExtractWhenStopped: true,
disabledProviders: ["onefichier"],
hosterRouting: {
"rapidgator.net": "realdebrid",
"ddownload.com": "debridlink"
},
providerDailyLimitBytes: {
realdebrid: 1099511627776,
debridlink: 536870912000
},
providerDailyUsageBytes: {
realdebrid: 214748364800,
debridlink: 107374182400
},
providerTotalUsageBytes: {
realdebrid: 8796093022208,
debridlink: 2199023255552
},
debridLinkApiKeyDailyLimitBytes: {
[debridLinkKeys[0].id]: 268435456000,
[debridLinkKeys[1].id]: 268435456000
},
debridLinkApiKeyDailyUsageBytes: {
[debridLinkKeys[0].id]: 53687091200,
[debridLinkKeys[1].id]: 26843545600
},
debridLinkApiKeyTotalUsageBytes: {
[debridLinkKeys[0].id]: 1099511627776,
[debridLinkKeys[1].id]: 549755813888
},
megaDebridDisabledAccountIds: [],
megaDebridAccountDailyLimitBytes: {
[megaAccountId]: 322122547200
},
megaDebridAccountDailyUsageBytes: {
[megaAccountId]: 64424509440
},
megaDebridAccountTotalUsageBytes: {
[megaAccountId]: 1649267441664
},
debridAccountStatuses: {
[megaAccountId]: {
accountId: megaAccountId,
provider: "megadebrid",
label: "Mega-Debrid Hauptkonto",
maskedLogin: "v***@example.test",
valid: true,
isPremium: true,
premiumUntilMs: 1798761600000,
email: "visual@example.test",
message: "Premium aktiv",
checkedAt: 1786312800000
},
[debridLinkKeys[0].id]: {
accountId: debridLinkKeys[0].id,
provider: "debridlink",
label: "Debrid-Link Key 1",
maskedLogin: debridLinkKeys[0].masked,
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "API-Key aktiv",
checkedAt: 1786312800000
},
[debridLinkKeys[1].id]: {
accountId: debridLinkKeys[1].id,
provider: "debridlink",
label: "Debrid-Link Key 2",
maskedLogin: debridLinkKeys[1].masked,
valid: true,
isPremium: true,
premiumUntilMs: null,
message: "API-Key aktiv",
checkedAt: 1786312800000
}
},
providerDailyUsageDay: "2026-08-10",
scheduledStartEpochMs: 0
};
}
function createEmptySnapshot(): UiSnapshot {
return {
settings: createSettings(),
session: {
version: 1,
packageOrder: [],
packages: {},
items: {},
runStartedAt: 0,
totalDownloadedBytes: 0,
summaryText: "Keine Downloads in der Warteschlange",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: false,
updatedAt: 1786312800000
},
summary: null,
stats: {
totalDownloaded: 0,
totalDownloadedAllTime: 987654321000,
totalFiles: 0,
totalFilesSession: 0,
totalFilesAllTime: 842,
totalPackages: 0,
sessionStartedAt: 1786309200000,
appSessionStartedAt: 1786309200000,
sessionRuntimeMs: 3600000,
totalRuntimeMs: 172800000,
runtimeMeasuredAt: 1786312800000
},
speedText: "0 B/s",
etaText: "--:--",
canStart: false,
canStop: false,
canPause: false,
clipboardActive: true,
reconnectSeconds: 0,
packageSpeedBps: {},
payloadKind: "full",
removedItemIds: [],
removedPackageIds: [],
rotationEvents: []
};
}
function createDenseSnapshot(): UiSnapshot {
const snapshot = createEmptySnapshot();
const debridLinkKeys = parseDebridLinkApiKeys(snapshot.settings.debridLinkApiKeys);
snapshot.session = {
version: 1,
packageOrder: ["visual-package-active", "visual-package-complete", "visual-package-failed"],
packages: {
"visual-package-active": {
id: "visual-package-active",
name: "Dokumentation Staffel 1",
outputDir: "C:\\Visual\\Downloads\\Dokumentation Staffel 1",
extractDir: "C:\\Visual\\Extracted\\Dokumentation Staffel 1",
status: "downloading",
itemIds: ["visual-item-active-1", "visual-item-active-2"],
cancelled: false,
enabled: true,
priority: "high",
postProcessLabel: "Automatisch entpacken",
downloadStartedAt: 1786311000000,
createdAt: 1786310400000,
updatedAt: 1786312800000
},
"visual-package-complete": {
id: "visual-package-complete",
name: "Konzertmitschnitt 2026",
outputDir: "C:\\Visual\\Downloads\\Konzertmitschnitt 2026",
extractDir: "C:\\Visual\\Extracted\\Konzertmitschnitt 2026",
status: "completed",
itemIds: ["visual-item-complete-1"],
cancelled: false,
enabled: true,
priority: "normal",
postProcessLabel: "Entpackt",
downloadStartedAt: 1786307400000,
downloadCompletedAt: 1786309200000,
createdAt: 1786306800000,
updatedAt: 1786309200000
},
"visual-package-failed": {
id: "visual-package-failed",
name: "Archiv mit Wiederholung",
outputDir: "C:\\Visual\\Downloads\\Archiv mit Wiederholung",
extractDir: "C:\\Visual\\Extracted\\Archiv mit Wiederholung",
status: "failed",
itemIds: ["visual-item-failed-1"],
cancelled: false,
enabled: true,
priority: "low",
postProcessLabel: "Wartet auf Wiederholung",
downloadStartedAt: 1786310100000,
createdAt: 1786309800000,
updatedAt: 1786312500000
}
},
items: {
"visual-item-active-1": {
id: "visual-item-active-1",
packageId: "visual-package-active",
url: "https://rapidgator.net/file/visual-active-1",
provider: "realdebrid",
providerLabel: "Real-Debrid",
providerAccountId: "visual-rd-account",
providerAccountLabel: "Real-Debrid Hauptkonto",
status: "downloading",
retries: 0,
speedBps: 12582912,
downloadedBytes: 3221225472,
totalBytes: 8589934592,
progressPercent: 37.5,
fileName: "dokumentation.s01e01.2160p.mkv",
targetPath: "C:\\Visual\\Downloads\\Dokumentation Staffel 1\\dokumentation.s01e01.2160p.mkv",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Download läuft",
createdAt: 1786310400000,
updatedAt: 1786312800000,
onlineStatus: "online"
},
"visual-item-active-2": {
id: "visual-item-active-2",
packageId: "visual-package-active",
url: "https://ddownload.com/visual-active-2",
provider: "debridlink",
providerLabel: "Debrid-Link",
providerAccountId: debridLinkKeys[0].id,
providerAccountLabel: "Debrid-Link Key 1",
status: "queued",
retries: 0,
speedBps: 0,
downloadedBytes: 0,
totalBytes: 7516192768,
progressPercent: 0,
fileName: "dokumentation.s01e02.2160p.mkv",
targetPath: "C:\\Visual\\Downloads\\Dokumentation Staffel 1\\dokumentation.s01e02.2160p.mkv",
resumable: true,
attempts: 0,
lastError: "",
fullStatus: "In Warteschlange",
createdAt: 1786310460000,
updatedAt: 1786312800000,
onlineStatus: "online"
},
"visual-item-complete-1": {
id: "visual-item-complete-1",
packageId: "visual-package-complete",
url: "https://rapidgator.net/file/visual-complete-1",
provider: "realdebrid",
providerLabel: "Real-Debrid",
providerAccountId: "visual-rd-account",
providerAccountLabel: "Real-Debrid Hauptkonto",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 12884901888,
totalBytes: 12884901888,
progressPercent: 100,
fileName: "konzertmitschnitt.2026.mkv",
targetPath: "C:\\Visual\\Downloads\\Konzertmitschnitt 2026\\konzertmitschnitt.2026.mkv",
resumable: true,
attempts: 1,
lastError: "",
fullStatus: "Abgeschlossen",
createdAt: 1786306800000,
updatedAt: 1786309200000,
onlineStatus: "online"
},
"visual-item-failed-1": {
id: "visual-item-failed-1",
packageId: "visual-package-failed",
url: "https://example.test/offline/visual-failed-1",
provider: "bestdebrid",
providerLabel: "BestDebrid",
providerAccountId: "visual-best-account",
providerAccountLabel: "BestDebrid Hauptkonto",
status: "failed",
retries: 3,
speedBps: 0,
downloadedBytes: 536870912,
totalBytes: 4294967296,
progressPercent: 12.5,
fileName: "archiv.part01.rar",
targetPath: "C:\\Visual\\Downloads\\Archiv mit Wiederholung\\archiv.part01.rar",
resumable: false,
attempts: 4,
lastError: "Hoster vorübergehend nicht verfügbar",
fullStatus: "Fehlgeschlagen nach 4 Versuchen",
createdAt: 1786309800000,
updatedAt: 1786312500000,
onlineStatus: "offline"
}
},
runStartedAt: 1786311000000,
totalDownloadedBytes: 16642998272,
summaryText: "1 aktiv, 1 wartet, 1 abgeschlossen, 1 fehlgeschlagen",
reconnectUntil: 0,
reconnectReason: "",
paused: false,
running: true,
updatedAt: 1786312800000
};
snapshot.summary = {
total: 4,
success: 1,
failed: 1,
cancelled: 0,
extracted: 1,
durationSeconds: 5400,
averageSpeedBps: 9437184
};
snapshot.stats = {
totalDownloaded: 16642998272,
totalDownloadedAllTime: 987654321000,
totalFiles: 4,
totalFilesSession: 4,
totalFilesAllTime: 842,
totalPackages: 3,
sessionStartedAt: 1786309200000,
appSessionStartedAt: 1786309200000,
sessionRuntimeMs: 3600000,
totalRuntimeMs: 172800000,
runtimeMeasuredAt: 1786312800000
};
snapshot.speedText = "12,0 MB/s";
snapshot.etaText = "00:17:24";
snapshot.canStart = true;
snapshot.canStop = true;
snapshot.canPause = true;
snapshot.packageSpeedBps = {
"visual-package-active": 12582912,
"visual-package-complete": 0,
"visual-package-failed": 0
};
snapshot.rotationEvents = [
{
id: "visual-rotation-event-1",
at: 1786312200000,
level: "WARN",
provider: "Debrid-Link",
accountLabel: "Debrid-Link Key 2",
event: "Account gewechselt",
reason: "Tageslimit erreicht",
category: "quota",
cooldownSec: 3600,
next: "Debrid-Link Key 1"
}
];
return snapshot;
}
function createDenseHistory(): HistoryEntry[] {
return [
{
id: "visual-history-1",
name: "Naturfilm Sammlung",
totalBytes: 25769803776,
downloadedBytes: 25769803776,
fileCount: 6,
provider: "realdebrid",
completedAt: 1786226400000,
durationSeconds: 1842,
status: "completed",
outputDir: "C:\\Visual\\Downloads\\Naturfilm Sammlung",
urls: [
"https://rapidgator.net/file/visual-history-1a",
"https://rapidgator.net/file/visual-history-1b"
]
},
{
id: "visual-history-2",
name: "Gelöschtes Testpaket",
totalBytes: 4294967296,
downloadedBytes: 4294967296,
fileCount: 1,
provider: "debridlink",
completedAt: 1786140000000,
durationSeconds: 722,
status: "deleted",
outputDir: "C:\\Visual\\Downloads\\Gelöschtes Testpaket",
urls: ["https://ddownload.com/visual-history-2"]
}
];
}
function createUpdate(updateAvailable: boolean): UpdateCheckResult {
return updateAvailable
? {
updateAvailable: true,
currentVersion: "2.0.12",
latestVersion: "9.9.9",
latestTag: "v9.9.9",
releaseUrl: "https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/tag/v9.9.9",
setupAssetUrl: "https://example.test/Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetName: "Multi-Debrid-Downloader-Setup-9.9.9.exe",
setupAssetDigest: "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
releaseNotes: "Neue kompakte Desktop-Oberfläche\nVerbesserte Accountübersicht\nPräzisere Statusanzeigen"
}
: {
updateAvailable: false,
currentVersion: "2.0.12",
latestVersion: "2.0.12",
latestTag: "v2.0.12",
releaseUrl: "https://github.com/Sucukdeluxe/multi-debrid-downloader/releases/tag/v2.0.12",
releaseNotes: "Aktuelle Version"
};
}
function createTraceConfig(): SupportTraceConfig {
return {
enabled: false,
includeMainLog: true,
includeAudit: true,
logDebugRequests: false,
autoDisableAt: null,
updatedAt: "2026-08-10T12:00:00.000Z"
};
}
function createRemoteDiagnostics(): RemoteDiagnosticsInfo {
return {
status: {
running: false,
host: "127.0.0.1",
port: 7843,
hasToken: true,
localOnly: true,
allowlistCount: 1
},
code: "VISUAL-CODE",
publicHost: "visual.example.test",
name: "Visual Harness",
allowlist: ["127.0.0.1"],
suggestedHosts: ["visual.example.test"]
};
}
export function createVisualFixture(scenario: VisualScenario): VisualFixture {
if (scenario === "empty") {
return {
snapshot: createEmptySnapshot(),
history: [],
update: createUpdate(false),
traceConfig: createTraceConfig(),
remoteDiagnostics: createRemoteDiagnostics()
};
}
return {
snapshot: createDenseSnapshot(),
history: createDenseHistory(),
update: createUpdate(scenario === "update"),
traceConfig: createTraceConfig(),
remoteDiagnostics: createRemoteDiagnostics()
};
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi Debrid Downloader Visual Harness</title>
<style>
*,
*::before,
*::after {
animation: none !important;
caret-color: transparent !important;
transition: none !important;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
import type { ReactElement } from "react";
import { createRoot } from "react-dom/client";
import { App } from "../../src/renderer/App";
import type { ElectronApi } from "../../src/shared/preload-api";
import "../../src/renderer/theme.css";
import "../../src/renderer/styles.css";
import {
createVisualFixture,
installVisualClock,
waitForVisualFrames,
type VisualScenario
} from "./fixtures";
import { createVisualElectronApi } from "./mock-electron-api";
import { loadVisualCapture, prepareVisualCapture } from "./ui-driver";
interface VisualHarnessRoot {
readonly innerText: string;
textContent: string | null;
readonly dataset: {
visualError?: string;
};
}
interface VisualReadyMarker {
visualReady?: string;
visualScenario?: string;
}
interface VisualReadyOptions {
marker: VisualReadyMarker;
loadVisualState: () => Promise<void>;
requestFrame: (callback: FrameRequestCallback) => number;
maxFrames?: number;
}
interface VisualRenderRoot {
render: (element: ReactElement) => void;
}
export interface VisualHarnessRuntime {
readonly search: string;
readonly rootElement: VisualHarnessRoot | null;
readonly marker: VisualReadyMarker;
readonly requestFrame: (callback: FrameRequestCallback) => number;
readonly maxFrames?: number;
installClock: () => void;
setElectronApi: (api: ElectronApi) => void;
createRoot: (rootElement: VisualHarnessRoot) => VisualRenderRoot;
}
const visibleScenarioContent = {
empty: ["Noch keine Downloads"],
dense: ["Dokumentation Staffel 1", "Konzertmitschnitt 2026"],
update: ["v9.9.9"]
} satisfies Record<VisualScenario, readonly string[]>;
function readScenario(search: string): VisualScenario {
const scenario = new URLSearchParams(search).get("scenario");
return scenario === "empty" || scenario === "update" ? scenario : "dense";
}
function missingVisibleContent(scenario: VisualScenario, rootElement: VisualHarnessRoot): string[] {
return visibleScenarioContent[scenario].filter((expected) => !rootElement.innerText.includes(expected));
}
function waitForVisualFrame(
requestFrame: (callback: FrameRequestCallback) => number
): Promise<void> {
return new Promise((resolve) => {
requestFrame(() => resolve());
});
}
export function renderVisualApp(render: (element: ReactElement) => void): void {
render(<App />);
}
export async function markVisualReady(
scenario: VisualScenario,
rootElement: VisualHarnessRoot,
options: VisualReadyOptions
): Promise<void> {
delete options.marker.visualReady;
delete rootElement.dataset.visualError;
await options.loadVisualState();
await waitForVisualFrames(options.requestFrame);
const maxFrames = options.maxFrames ?? 180;
for (let frame = 0; frame <= maxFrames; frame += 1) {
const missing = missingVisibleContent(scenario, rootElement);
if (missing.length === 0) {
options.marker.visualReady = "true";
return;
}
if (frame < maxFrames) {
await waitForVisualFrame(options.requestFrame);
}
}
const missing = missingVisibleContent(scenario, rootElement);
throw new Error(
`Visual-Harness-Szenario "${scenario}" ist nicht bereit: ${missing.map((value) => `${value} fehlt`).join(", ")}`
);
}
export function showVisualHarnessError(
rootElement: VisualHarnessRoot,
marker: VisualReadyMarker,
error: unknown
): void {
delete marker.visualReady;
rootElement.dataset.visualError = "true";
rootElement.textContent = `Visual-Harness-Fehler: ${error instanceof Error ? error.message : String(error)}`;
}
function createBrowserVisualHarnessRuntime(): VisualHarnessRuntime {
const rootElement = document.getElementById("root");
return {
search: window.location.search,
rootElement,
marker: document.documentElement.dataset,
requestFrame: window.requestAnimationFrame.bind(window),
installClock(): void {
installVisualClock(window);
},
setElectronApi(api: ElectronApi): void {
window.rd = api;
},
createRoot(element: VisualHarnessRoot): VisualRenderRoot {
if (rootElement === null || element !== rootElement) {
throw new Error("Root element fehlt");
}
return createRoot(rootElement);
}
};
}
export async function startVisualHarness(
runtime: VisualHarnessRuntime = createBrowserVisualHarnessRuntime()
): Promise<void> {
const rootElement = runtime.rootElement;
if (!rootElement) {
throw new Error("Root element fehlt");
}
try {
const captureName = new URLSearchParams(runtime.search).get("capture");
const capture = captureName ? await loadVisualCapture(captureName) : undefined;
const scenario = capture?.scenario ?? readScenario(runtime.search);
runtime.installClock();
const fixture = createVisualFixture(scenario);
const api = createVisualElectronApi(fixture);
runtime.setElectronApi(api);
runtime.marker.visualScenario = scenario;
const root = runtime.createRoot(rootElement);
renderVisualApp((element) => root.render(element));
const readyOptions = {
loadVisualState: async () => {
await Promise.all([api.getSnapshot(), api.getHistory()]);
},
requestFrame: runtime.requestFrame,
maxFrames: runtime.maxFrames
};
if (!capture) {
await markVisualReady(scenario, rootElement, {
marker: runtime.marker,
...readyOptions
});
return;
}
delete runtime.marker.visualReady;
await markVisualReady(scenario, rootElement, {
marker: {},
...readyOptions
});
await prepareVisualCapture(capture, document);
runtime.marker.visualReady = "true";
} catch (error) {
showVisualHarnessError(rootElement, runtime.marker, error);
}
}
if (typeof window !== "undefined" && typeof document !== "undefined") {
startVisualHarness();
}
+405
View File
@@ -0,0 +1,405 @@
import type { ElectronApi } from "../../src/shared/preload-api";
import type { AppSettings, HistoryEntry } from "../../src/shared/types";
import { parseDebridLinkApiKeys } from "../../src/shared/debrid-link-keys";
import type { VisualFixture } from "./fixtures";
const stableNoopUnsubscribe = (): void => {};
function clone<T>(value: T): T {
return structuredClone(value);
}
export function createVisualElectronApi(
fixture: VisualFixture,
search = typeof window === "undefined" ? "" : window.location.search
): ElectronApi {
const historyState = new URLSearchParams(search).get("history-state");
let historyRequestCount = 0;
const updateSettings = (settings: Partial<AppSettings>): AppSettings => {
Object.assign(fixture.snapshot.settings, settings);
return clone(fixture.snapshot.settings);
};
return {
getSnapshot: async () => clone(fixture.snapshot),
getVersion: async () => "2.0.12",
checkUpdates: async () => clone(fixture.update),
installUpdate: async () => ({ started: true, message: "Visual update gestartet" }),
openExternal: async () => true,
updateSettings: async (settings) => updateSettings(settings),
resetProviderDailyUsage: async (provider) => {
fixture.snapshot.settings.providerDailyUsageBytes[provider] = 0;
return clone(fixture.snapshot.settings);
},
resetDebridLinkApiKeyDailyUsage: async (keyId) => {
fixture.snapshot.settings.debridLinkApiKeyDailyUsageBytes[keyId] = 0;
return clone(fixture.snapshot.settings);
},
addLinks: async () => ({ addedPackages: 0, addedLinks: 0, invalidCount: 0 }),
addContainers: async () => ({ addedPackages: 0, addedLinks: 0 }),
getStartConflicts: async () => [],
resolveStartConflict: async (_packageId, policy) => ({
skipped: policy === "skip",
overwritten: policy === "overwrite"
}),
clearAll: async () => {
fixture.snapshot.session.packageOrder = [];
fixture.snapshot.session.packages = {};
fixture.snapshot.session.items = {};
fixture.snapshot.session.running = false;
fixture.snapshot.session.paused = false;
fixture.snapshot.session.totalDownloadedBytes = 0;
fixture.snapshot.packageSpeedBps = {};
fixture.snapshot.canStart = false;
fixture.snapshot.canStop = false;
fixture.snapshot.canPause = false;
},
start: async () => {
fixture.snapshot.session.running = true;
fixture.snapshot.session.paused = false;
fixture.snapshot.canStop = true;
fixture.snapshot.canPause = true;
},
startPackages: async (packageIds) => {
for (const packageId of packageIds) {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "downloading";
}
}
fixture.snapshot.session.running = true;
},
stop: async () => {
fixture.snapshot.session.running = false;
fixture.snapshot.session.paused = false;
fixture.snapshot.canStop = false;
fixture.snapshot.canPause = false;
},
togglePause: async () => {
fixture.snapshot.session.paused = !fixture.snapshot.session.paused;
return fixture.snapshot.session.paused;
},
cancelPackage: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.cancelled = true;
entry.status = "cancelled";
}
},
renamePackage: async (packageId, newName) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.name = newName;
}
},
reorderPackages: async (packageIds) => {
fixture.snapshot.session.packageOrder = [...packageIds];
},
removeItem: async (itemId) => {
const item = fixture.snapshot.session.items[itemId];
if (item) {
const entry = fixture.snapshot.session.packages[item.packageId];
if (entry) {
entry.itemIds = entry.itemIds.filter((id) => id !== itemId);
}
delete fixture.snapshot.session.items[itemId];
}
},
togglePackage: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.enabled = !entry.enabled;
}
},
exportPackageSelection: async (packageIds) => ({
saved: true,
packageCount: packageIds.length,
linkCount: packageIds.reduce(
(count, packageId) => count + (fixture.snapshot.session.packages[packageId]?.itemIds.length ?? 0),
0
),
filePath: "C:\\Visual\\Exports\\packages.txt"
}),
exportItemSelection: async (itemIds) => ({
saved: true,
packageCount: new Set(
itemIds.map((itemId) => fixture.snapshot.session.items[itemId]?.packageId).filter(Boolean)
).size,
linkCount: itemIds.length,
filePath: "C:\\Visual\\Exports\\items.txt"
}),
exportQueue: async () => ({ saved: true }),
importQueue: async () => ({ addedPackages: 0, addedLinks: 0 }),
toggleClipboard: async () => {
fixture.snapshot.clipboardActive = !fixture.snapshot.clipboardActive;
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
return fixture.snapshot.clipboardActive;
},
pickFolder: async () => "C:\\Visual\\Selected",
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
getSessionStats: async () => ({
bandwidth: {
samples: [
{ timestamp: 1786312680000, speedBps: 10485760 },
{ timestamp: 1786312740000, speedBps: 11534336 },
{ timestamp: 1786312800000, speedBps: 12582912 }
],
currentSpeedBps: 12582912,
averageSpeedBps: 11534336,
maxSpeedBps: 15728640,
totalBytesSession: 16642998272,
sessionDurationSeconds: 3600
},
totalDownloads: 4,
completedDownloads: 1,
failedDownloads: 1,
activeDownloads: 1,
queuedDownloads: 1
}),
resetSessionStats: async () => {
fixture.snapshot.stats.totalDownloaded = 0;
fixture.snapshot.stats.totalFilesSession = 0;
fixture.snapshot.session.totalDownloadedBytes = 0;
},
resetDownloadStats: async () => {
fixture.snapshot.stats.totalDownloadedAllTime = 0;
fixture.snapshot.stats.totalFilesAllTime = 0;
fixture.snapshot.settings.totalDownloadedAllTime = 0;
fixture.snapshot.settings.totalCompletedFilesAllTime = 0;
fixture.snapshot.settings.totalRuntimeAllTimeMs = 0;
},
restart: async () => {},
quit: async () => {},
exportBackup: async () => ({ saved: true }),
importBackup: async () => ({ restored: true, relaunch: false, message: "Visual backup importiert" }),
exportOnlineBackup: async () => ({ key: "visual-online-backup-key" }),
importOnlineBackup: async () => ({ restored: true, relaunch: false, message: "Visual online backup importiert" }),
exportSupportBundle: async () => ({ saved: true, filePath: "C:\\Visual\\Support\\support.zip" }),
openLog: async () => {},
openAuditLog: async () => {},
openRenameLog: async () => {},
openSessionLog: async () => {},
openTraceLog: async () => {},
openPackageLog: async () => {},
openItemLog: async () => {},
getDebugSetupCheck: async () => ({
status: "ok",
enabled: false,
runtimeBaseDir: "C:\\Visual\\Runtime",
host: "127.0.0.1",
port: 7843,
localOnly: true,
tokenConfigured: true,
tokenPath: "C:\\Visual\\Runtime\\debug-token",
supportManifestPath: "C:\\Visual\\Runtime\\support-manifest.json",
supportManifestPresent: true,
traceConfigPath: "C:\\Visual\\Runtime\\trace-config.json",
traceLogPath: "C:\\Visual\\Runtime\\trace.log",
traceEnabled: fixture.traceConfig.enabled,
traceAutoDisableAt: fixture.traceConfig.autoDisableAt,
diskSpace: {
runtime: { path: "C:\\Visual\\Runtime", totalBytes: 1099511627776, freeBytes: 549755813888, freePercent: 50 },
output: { path: "C:\\Visual\\Downloads", totalBytes: 1099511627776, freeBytes: 549755813888, freePercent: 50 },
extract: { path: "C:\\Visual\\Extracted", totalBytes: 1099511627776, freeBytes: 549755813888, freePercent: 50 }
},
logSummary: {
totalBytes: 12288,
main: { path: "C:\\Visual\\Runtime\\main.log", exists: true, bytes: 4096 },
mainBackup: { path: null, exists: false, bytes: 0 },
audit: { path: "C:\\Visual\\Runtime\\audit.log", exists: true, bytes: 2048 },
auditBackup: { path: null, exists: false, bytes: 0 },
rename: { path: "C:\\Visual\\Runtime\\rename.log", exists: true, bytes: 1024 },
renameBackup: { path: null, exists: false, bytes: 0 },
session: { path: "C:\\Visual\\Runtime\\session.log", exists: true, bytes: 2048 },
trace: { path: "C:\\Visual\\Runtime\\trace.log", exists: true, bytes: 3072 },
traceBackup: { path: null, exists: false, bytes: 0 },
sessionLogs: { path: "C:\\Visual\\Runtime\\sessions", exists: true, fileCount: 2, bytes: 2048 },
packageLogs: { path: "C:\\Visual\\Runtime\\packages", exists: true, fileCount: 3, bytes: 3072 },
itemLogs: { path: "C:\\Visual\\Runtime\\items", exists: true, fileCount: 4, bytes: 4096 }
},
supportBundle: {
estimatedBytes: 24576,
estimatedEntries: 12,
duplicatedLiveLogBytes: 0,
note: "Visual support bundle"
},
warnings: [],
notes: ["Deterministischer Visual-Harness"],
localUrls: {
health: "http://127.0.0.1:7843/health",
meta: "http://127.0.0.1:7843/meta",
diagnostics: "http://127.0.0.1:7843/diagnostics"
},
remoteUrlTemplates: {
health: "https://visual.example.test/health",
meta: "https://visual.example.test/meta",
diagnostics: "https://visual.example.test/diagnostics"
}
}),
getRecentErrors: async () => [
{ ts: "2026-08-10T11:55:00.000Z", level: "WARN", message: "Visualer Beispielhinweis" }
],
testNotification: async () => true,
getTraceConfig: async () => clone(fixture.traceConfig),
setTraceEnabled: async (enabled) => {
fixture.traceConfig = { ...fixture.traceConfig, enabled };
return clone(fixture.traceConfig);
},
rotateDebugToken: async () => ({ path: "C:\\Visual\\Runtime\\debug-token" }),
getRemoteDiagnostics: async () => clone(fixture.remoteDiagnostics),
enableRemoteDiagnostics: async (input) => {
fixture.remoteDiagnostics = {
...fixture.remoteDiagnostics,
status: {
...fixture.remoteDiagnostics.status,
running: true,
host: input.hostMode === "local" ? "127.0.0.1" : "0.0.0.0",
port: input.port ?? 7843,
localOnly: input.hostMode === "local",
allowlistCount: input.allowlist.length
},
publicHost: input.publicHost,
name: input.name ?? fixture.remoteDiagnostics.name,
allowlist: [...input.allowlist]
};
return clone(fixture.remoteDiagnostics);
},
disableRemoteDiagnostics: async () => {
fixture.remoteDiagnostics = {
...fixture.remoteDiagnostics,
status: { ...fixture.remoteDiagnostics.status, running: false }
};
return clone(fixture.remoteDiagnostics);
},
rotateRemoteDiagnosticsToken: async () => clone(fixture.remoteDiagnostics),
openRealDebridLogin: async () => {},
openAllDebridLogin: async () => {},
importBestDebridCookies: async () => 2,
getAllDebridHostInfo: async () => ({
host: "rapidgator.net",
source: "api",
state: "up",
statusLabel: "Verfügbar",
fetchedAt: 1786312800000,
lastCheckedAt: 1786312740000,
quota: 42,
quotaMax: 100,
quotaType: "daily",
limitSimuDl: 8,
note: "Visual host status"
}),
getDebridLinkHostLimits: async () => {
const primaryKey = parseDebridLinkApiKeys(fixture.snapshot.settings.debridLinkApiKeys)[0];
return [{
keyId: primaryKey.id,
keyLabel: primaryKey.label,
host: "ddownload.com",
fetchedAt: 1786312800000,
trafficCurrentBytes: 53687091200,
trafficMaxBytes: 268435456000,
linksCurrent: 12,
linksMax: 100,
note: "Visual quota",
state: "ready",
stateLabel: "Bereit",
stateDetail: "Kontingent verfügbar",
cooldownUntil: null,
cooldownRemainingMs: 0,
lastCheckedAt: 1786312740000,
hostState: "up",
hostStateLabel: "Online",
hostNote: "Hoster verfügbar"
}];
},
checkDebridAccounts: async () => clone(Object.values(fixture.snapshot.settings.debridAccountStatuses)),
checkMegaDebridAccount: async () => {
const status = Object.values(fixture.snapshot.settings.debridAccountStatuses).find(
(entry) => entry.provider === "megadebrid"
);
return status ? clone(status) : null;
},
retryExtraction: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "extracting";
}
},
extractNow: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "extracting";
}
},
resetPackage: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "queued";
entry.cancelled = false;
}
},
getHistory: async () => {
historyRequestCount += 1;
if (historyRequestCount > 1 && historyState === "loading") {
return new Promise<HistoryEntry[]>(() => {});
}
if (historyRequestCount > 1 && historyState === "error") {
throw new Error("Visual history load failed");
}
return clone(fixture.history);
},
clearHistory: async () => {
fixture.history.splice(0, fixture.history.length);
},
removeHistoryEntry: async (entryId) => {
const index = fixture.history.findIndex((entry) => entry.id === entryId);
if (index >= 0) {
fixture.history.splice(index, 1);
}
},
revealHistoryEntry: async (entryId) => fixture.history.some((entry) => entry.id === entryId)
? { ok: true }
: { ok: false, reason: "entry-not-found" },
setPackagePriority: async (packageId, priority) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.priority = priority;
}
},
skipItems: async (itemIds) => {
for (const itemId of itemIds) {
const item = fixture.snapshot.session.items[itemId];
if (item) {
item.status = "cancelled";
item.fullStatus = "Übersprungen";
}
}
},
resetItems: async (itemIds) => {
for (const itemId of itemIds) {
const item = fixture.snapshot.session.items[itemId];
if (item) {
item.status = "queued";
item.downloadedBytes = 0;
item.progressPercent = 0;
item.speedBps = 0;
item.lastError = "";
item.fullStatus = "In Warteschlange";
}
}
},
startItems: async (itemIds) => {
for (const itemId of itemIds) {
const item = fixture.snapshot.session.items[itemId];
if (item) {
item.status = "downloading";
item.fullStatus = "Download läuft";
}
}
fixture.snapshot.session.running = true;
},
reportRendererError: () => {},
onStateUpdate: () => stableNoopUnsubscribe,
onClipboardDetected: () => stableNoopUnsubscribe,
onUpdateInstallProgress: () => stableNoopUnsubscribe
};
}
+546
View File
@@ -0,0 +1,546 @@
import { VISUAL_SCENARIOS, type VisualScenario } from "./fixtures";
export type MainViewId = "downloads" | "collector" | "settings" | "history" | "statistics";
export interface VisualInteraction {
type: "click" | "hover" | "fill" | "press" | "wait-visible" | "wait-absent";
role?: string;
name?: string;
value?: string;
key?: string;
}
export interface VisualAssertion {
type: "active-view" | "visible" | "absent" | "nonempty" | "minimum-row-count" | "layer-above";
role?: string;
name?: string;
region?: string;
value?: string | number;
referenceRole?: string;
referenceName?: string;
}
export interface VisualCapture {
name: string;
scenario: VisualScenario;
viewport: {
width: number;
height: number;
};
activeView: MainViewId;
interactions: VisualInteraction[];
assertions: VisualAssertion[];
}
const MAIN_VIEWS = ["downloads", "collector", "settings", "history", "statistics"] as const;
const INTERACTION_TYPES = ["click", "hover", "fill", "press", "wait-visible", "wait-absent"] as const;
const ASSERTION_TYPES = ["active-view", "visible", "absent", "nonempty", "minimum-row-count", "layer-above"] as const;
const REGION_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const VIEW_NAMES: Record<MainViewId, string> = {
downloads: "Downloads",
collector: "Linksammler",
settings: "Einstellungen",
history: "Verlauf",
statistics: "Statistiken"
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isNonemptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isOneOf<T extends string>(value: unknown, choices: readonly T[]): value is T {
return typeof value === "string" && choices.includes(value as T);
}
function validateRoleName(value: Record<string, unknown>, path: string, errors: string[]): void {
if (!isNonemptyString(value.role)) {
errors.push(`${path}.role must be a nonempty string`);
}
if (!isNonemptyString(value.name)) {
errors.push(`${path}.name must be a nonempty string`);
}
}
function validateRegion(value: unknown, path: string, errors: string[]): void {
if (!isNonemptyString(value) || !REGION_PATTERN.test(value)) {
errors.push(`${path} must match ^[a-z0-9]+(?:-[a-z0-9]+)*$`);
}
}
function validateInteraction(value: unknown, path: string, errors: string[]): void {
if (!isRecord(value)) {
errors.push(`${path} must be an object`);
return;
}
if (!isOneOf(value.type, INTERACTION_TYPES)) {
errors.push(`${path}.type must be one of ${INTERACTION_TYPES.join(", ")}`);
return;
}
validateRoleName(value, path, errors);
if (value.type === "fill" && typeof value.value !== "string") {
errors.push(`${path}.value must be a string`);
}
if (value.type === "press" && !isNonemptyString(value.key)) {
errors.push(`${path}.key must be a nonempty string`);
}
}
function validateAssertion(value: unknown, path: string, errors: string[]): void {
if (!isRecord(value)) {
errors.push(`${path} must be an object`);
return;
}
if (!isOneOf(value.type, ASSERTION_TYPES)) {
errors.push(`${path}.type must be one of ${ASSERTION_TYPES.join(", ")}`);
return;
}
if (value.type === "active-view") {
if (!isOneOf(value.value, MAIN_VIEWS)) {
errors.push(`${path}.value must be one of ${MAIN_VIEWS.join(", ")}`);
}
return;
}
if (value.type === "minimum-row-count") {
validateRegion(value.region, `${path}.region`, errors);
if (!Number.isInteger(value.value) || Number(value.value) < 0) {
errors.push(`${path}.value must be a nonnegative integer`);
}
return;
}
if (value.type === "layer-above") {
validateRoleName(value, path, errors);
if (!isNonemptyString(value.referenceRole)) {
errors.push(`${path}.referenceRole must be a nonempty string`);
}
if (!isNonemptyString(value.referenceName)) {
errors.push(`${path}.referenceName must be a nonempty string`);
}
return;
}
if (value.region !== undefined) {
validateRegion(value.region, `${path}.region`, errors);
return;
}
validateRoleName(value, path, errors);
}
export function validateVisualCaptureManifest(input: unknown): string[] {
if (!Array.isArray(input)) {
return ["$ must be an array"];
}
const errors: string[] = [];
const names = new Set<string>();
input.forEach((value, index) => {
const path = `$[${index}]`;
const entry = isRecord(value) ? value : {};
if (!isRecord(value)) {
errors.push(`${path} must be an object`);
}
if (!isNonemptyString(entry.name)) {
errors.push(`${path}.name must be a nonempty string`);
} else if (names.has(entry.name)) {
errors.push(`${path}.name must be unique`);
} else {
names.add(entry.name);
}
if (!isOneOf(entry.scenario, VISUAL_SCENARIOS)) {
errors.push(`${path}.scenario must be one of ${VISUAL_SCENARIOS.join(", ")}`);
}
if (!isRecord(entry.viewport)) {
errors.push(`${path}.viewport must be an object`);
} else {
if (!Number.isInteger(entry.viewport.width) || Number(entry.viewport.width) <= 0) {
errors.push(`${path}.viewport.width must be a positive integer`);
}
if (!Number.isInteger(entry.viewport.height) || Number(entry.viewport.height) <= 0) {
errors.push(`${path}.viewport.height must be a positive integer`);
}
}
if (!isOneOf(entry.activeView, MAIN_VIEWS)) {
errors.push(`${path}.activeView must be one of ${MAIN_VIEWS.join(", ")}`);
}
if (!Array.isArray(entry.interactions)) {
errors.push(`${path}.interactions must be an array`);
} else {
entry.interactions.forEach((interaction, interactionIndex) => {
validateInteraction(interaction, `${path}.interactions[${interactionIndex}]`, errors);
});
}
if (!Array.isArray(entry.assertions)) {
errors.push(`${path}.assertions must be an array`);
} else {
entry.assertions.forEach((assertion, assertionIndex) => {
validateAssertion(assertion, `${path}.assertions[${assertionIndex}]`, errors);
});
}
});
return errors;
}
export async function loadVisualCapture(name: string): Promise<VisualCapture> {
const response = await fetch(new URL("./capture-manifest.json", import.meta.url));
if (!response.ok) {
throw new Error(`capture manifest could not be loaded: ${response.status}`);
}
const manifest: unknown = await response.json();
const errors = validateVisualCaptureManifest(manifest);
if (errors.length > 0) {
throw new Error(`capture manifest is invalid: ${errors.join("; ")}`);
}
const capture = (manifest as VisualCapture[]).find((entry) => entry.name === name);
if (!capture) {
throw new Error(`capture "${name}" is missing`);
}
return capture;
}
function roleForElement(element: Element): string | null {
const explicitRole = element.getAttribute("role");
if (explicitRole) {
return explicitRole;
}
const tagName = element.tagName.toLowerCase();
if (tagName === "button") {
return "button";
}
if (tagName === "textarea") {
return "textbox";
}
if (tagName === "input") {
const type = (element.getAttribute("type") ?? "text").toLowerCase();
if (["text", "email", "search", "tel", "url", "password"].includes(type)) {
return "textbox";
}
}
if (tagName === "section" && accessibleName(element).length > 0) {
return "region";
}
return null;
}
function accessibleName(element: Element): string {
const ariaLabel = element.getAttribute("aria-label");
if (ariaLabel !== null) {
return ariaLabel.trim();
}
const labelledBy = element.getAttribute("aria-labelledby");
if (labelledBy) {
const ownerDocument = element.ownerDocument;
const name = labelledBy
.split(/\s+/)
.map((id) => ownerDocument?.getElementById(id)?.textContent?.trim() ?? "")
.filter(Boolean)
.join(" ");
if (name) {
return name;
}
}
return element.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
function isVisible(element: Element, targetDocument: Document): boolean {
if (element.hasAttribute("hidden") || element.getAttribute("aria-hidden") === "true") {
return false;
}
const style = targetDocument.defaultView?.getComputedStyle(element);
if (style && (
style.display === "none"
|| style.visibility === "hidden"
|| style.visibility === "collapse"
|| style.opacity === "0"
)) {
return false;
}
return element.getClientRects().length > 0;
}
function targetLabel(role: string, name: string): string {
return `${role} "${name}"`;
}
function roleMatches(targetDocument: Document, role: string, name: string): Element[] {
return Array.from(targetDocument.querySelectorAll("*")).filter((element) => (
roleForElement(element) === role
&& accessibleName(element) === name
&& isVisible(element, targetDocument)
));
}
function resolveRole(targetDocument: Document, role: string, name: string): Element {
const matches = roleMatches(targetDocument, role, name);
if (matches.length === 0) {
throw new Error(`${targetLabel(role, name)} is missing`);
}
if (matches.length > 1) {
throw new Error(`${targetLabel(role, name)} is ambiguous`);
}
return matches[0];
}
function regionMatches(targetDocument: Document, region: string): Element[] {
if (!REGION_PATTERN.test(region)) {
throw new Error(`region "${region}" is invalid`);
}
return Array.from(targetDocument.querySelectorAll("[data-visual-region]"))
.filter((element) => element.getAttribute("data-visual-region") === region)
.filter((element) => isVisible(element, targetDocument));
}
function resolveRegion(targetDocument: Document, region: string): Element {
const matches = regionMatches(targetDocument, region);
if (matches.length === 0) {
throw new Error(`region marker "${region}" is missing`);
}
if (matches.length > 1) {
throw new Error(`region marker "${region}" is ambiguous`);
}
return matches[0];
}
function requestFrame(targetDocument: Document): Promise<void> {
return new Promise((resolve) => {
const request = targetDocument.defaultView?.requestAnimationFrame;
if (!request) {
resolve();
return;
}
request.call(targetDocument.defaultView, () => resolve());
});
}
async function waitForStableDom(targetDocument: Document): Promise<void> {
await requestFrame(targetDocument);
await requestFrame(targetDocument);
}
function createEvent(targetDocument: Document, type: string, kind: "event" | "mouse" | "pointer" | "keyboard", key = ""): Event | null {
const view = targetDocument.defaultView;
if (!view) {
return null;
}
if (kind === "keyboard") {
return new view.KeyboardEvent(type, { bubbles: true, cancelable: true, key });
}
if (kind === "pointer" && typeof view.PointerEvent === "function") {
return new view.PointerEvent(type, { bubbles: true, cancelable: true });
}
if (kind === "mouse" || kind === "pointer") {
return new view.MouseEvent(type, { bubbles: true, cancelable: true });
}
return new view.Event(type, { bubbles: true, cancelable: true });
}
function dispatch(element: Element, event: Event | null): void {
if (event && typeof element.dispatchEvent === "function") {
element.dispatchEvent(event);
}
}
function focusElement(element: Element): void {
if ("focus" in element && typeof element.focus === "function") {
element.focus();
}
}
function clickElement(targetDocument: Document, element: Element): void {
focusElement(element);
dispatch(element, createEvent(targetDocument, "pointerdown", "pointer"));
dispatch(element, createEvent(targetDocument, "mousedown", "mouse"));
dispatch(element, createEvent(targetDocument, "pointerup", "pointer"));
dispatch(element, createEvent(targetDocument, "mouseup", "mouse"));
if ("click" in element && typeof element.click === "function") {
element.click();
} else {
dispatch(element, createEvent(targetDocument, "click", "mouse"));
}
}
function hoverElement(targetDocument: Document, element: Element): void {
dispatch(element, createEvent(targetDocument, "pointerover", "pointer"));
dispatch(element, createEvent(targetDocument, "pointerenter", "pointer"));
dispatch(element, createEvent(targetDocument, "mouseover", "mouse"));
dispatch(element, createEvent(targetDocument, "mouseenter", "mouse"));
}
function setNativeValue(targetDocument: Document, element: Element, value: string): void {
const view = targetDocument.defaultView;
if (!view) {
throw new Error("document window is missing");
}
let prototype: object | null = Object.getPrototypeOf(element);
let setter: ((this: Element, nextValue: string) => void) | undefined;
while (prototype && !setter) {
setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set as typeof setter;
prototype = Object.getPrototypeOf(prototype);
}
if (!setter) {
throw new Error("textbox native value setter is missing");
}
focusElement(element);
setter.call(element, value);
dispatch(element, createEvent(targetDocument, "input", "event"));
dispatch(element, createEvent(targetDocument, "change", "event"));
}
function pressElement(targetDocument: Document, element: Element, key: string): void {
focusElement(element);
dispatch(element, createEvent(targetDocument, "keydown", "keyboard", key));
dispatch(element, createEvent(targetDocument, "keyup", "keyboard", key));
}
async function waitForRoleState(
targetDocument: Document,
role: string,
name: string,
present: boolean,
maxFrames = 180
): Promise<void> {
for (let frame = 0; frame <= maxFrames; frame += 1) {
const count = roleMatches(targetDocument, role, name).length;
if ((present && count === 1) || (!present && count === 0)) {
return;
}
if (present && count > 1) {
throw new Error(`${targetLabel(role, name)} is ambiguous`);
}
if (frame < maxFrames) {
await requestFrame(targetDocument);
}
}
throw new Error(`${targetLabel(role, name)} did not become ${present ? "visible" : "absent"}`);
}
async function activateView(activeView: MainViewId, targetDocument: Document): Promise<void> {
const name = VIEW_NAMES[activeView];
const tabMatches = roleMatches(targetDocument, "tab", name);
const buttonMatches = roleMatches(targetDocument, "button", name);
const viewButtonMatches = buttonMatches.filter((element) => element.classList.contains("tab"));
const matches = tabMatches.length > 0
? tabMatches
: viewButtonMatches.length > 0
? viewButtonMatches
: buttonMatches;
if (matches.length === 0) {
throw new Error(`tab or button "${name}" is missing`);
}
if (matches.length > 1) {
throw new Error(`tab or button "${name}" is ambiguous`);
}
clickElement(targetDocument, matches[0]);
await waitForStableDom(targetDocument);
}
async function runInteraction(interaction: VisualInteraction, targetDocument: Document): Promise<void> {
const role = interaction.role as string;
const name = interaction.name as string;
if (interaction.type === "wait-visible" || interaction.type === "wait-absent") {
await waitForRoleState(targetDocument, role, name, interaction.type === "wait-visible");
await waitForStableDom(targetDocument);
return;
}
const element = resolveRole(targetDocument, role, name);
if (interaction.type === "click") {
clickElement(targetDocument, element);
} else if (interaction.type === "hover") {
hoverElement(targetDocument, element);
} else if (interaction.type === "fill") {
setNativeValue(targetDocument, element, interaction.value as string);
} else {
pressElement(targetDocument, element, interaction.key as string);
}
await waitForStableDom(targetDocument);
}
function resolveAssertionElement(assertion: VisualAssertion, targetDocument: Document): Element {
if (assertion.region) {
return resolveRegion(targetDocument, assertion.region);
}
return resolveRole(targetDocument, assertion.role as string, assertion.name as string);
}
function assertActiveView(activeView: MainViewId, targetDocument: Document): void {
const marker = targetDocument.querySelector(`[data-visual-active-view="${activeView}"]`);
if (marker && isVisible(marker, targetDocument)) {
return;
}
const name = VIEW_NAMES[activeView];
const candidates = [
...roleMatches(targetDocument, "tab", name),
...roleMatches(targetDocument, "button", name)
];
const active = candidates.filter((element) => (
element.getAttribute("aria-current") === "page"
|| element.getAttribute("aria-selected") === "true"
|| element.classList.contains("active")
));
if (active.length !== 1) {
throw new Error(`active view "${activeView}" is missing`);
}
}
function rowCount(element: Element, targetDocument: Document): number {
return Array.from(element.querySelectorAll('[role="row"]')).filter((row) => isVisible(row, targetDocument)).length;
}
function assertCapture(assertion: VisualAssertion, targetDocument: Document): void {
if (assertion.type === "active-view") {
assertActiveView(assertion.value as MainViewId, targetDocument);
return;
}
if (assertion.type === "absent") {
if (assertion.region) {
const matches = regionMatches(targetDocument, assertion.region);
if (matches.length > 1) {
throw new Error(`region marker "${assertion.region}" is ambiguous`);
}
if (matches.length > 0) {
throw new Error(`region marker "${assertion.region}" is visible`);
}
} else {
const matches = roleMatches(targetDocument, assertion.role as string, assertion.name as string);
if (matches.length > 0) {
throw new Error(`${targetLabel(assertion.role as string, assertion.name as string)} is visible`);
}
}
return;
}
const element = resolveAssertionElement(assertion, targetDocument);
if (assertion.type === "visible") {
return;
}
if (assertion.type === "nonempty") {
if (!(element.textContent ?? "").trim()) {
throw new Error(`${assertion.region ? `region marker "${assertion.region}"` : targetLabel(assertion.role as string, assertion.name as string)} is empty`);
}
return;
}
if (assertion.type === "minimum-row-count") {
const count = rowCount(element, targetDocument);
if (count < Number(assertion.value)) {
throw new Error(`region marker "${assertion.region}" has ${count} rows, expected at least ${assertion.value}`);
}
return;
}
const reference = resolveRole(targetDocument, assertion.referenceRole as string, assertion.referenceName as string);
const view = targetDocument.defaultView;
const zIndex = Number.parseInt(view?.getComputedStyle(element).zIndex ?? "", 10);
const referenceZIndex = Number.parseInt(view?.getComputedStyle(reference).zIndex ?? "", 10);
if (!Number.isFinite(zIndex) || !Number.isFinite(referenceZIndex) || zIndex <= referenceZIndex) {
throw new Error(`${targetLabel(assertion.role as string, assertion.name as string)} is not layered above ${targetLabel(assertion.referenceRole as string, assertion.referenceName as string)}`);
}
}
export async function prepareVisualCapture(capture: VisualCapture, targetDocument: Document): Promise<void> {
await activateView(capture.activeView, targetDocument);
for (const interaction of capture.interactions) {
await runInteraction(interaction, targetDocument);
}
for (const assertion of capture.assertions) {
assertCapture(assertion, targetDocument);
}
await waitForStableDom(targetDocument);
}
+14
View File
@@ -0,0 +1,14 @@
import path from "node:path";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
root: path.resolve(__dirname),
publicDir: path.resolve(__dirname, "../../assets"),
server: {
fs: {
allow: [path.resolve(__dirname, "../..")]
}
}
});
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
environment: "node", environment: "node",
include: ["tests/**/*.test.ts", "scripts/tests/**/*.test.ts"], include: ["tests/**/*.test.ts", "tests/**/*.test.tsx", "scripts/tests/**/*.test.ts"],
globals: true globals: true
} }
}); });