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:
@@ -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/
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>1–1 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("1–1 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("1–260 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]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("1–2 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;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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=");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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="1–25 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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user